Evotec https://evotec.xyz/ Services for small, medium and large business Sun, 09 Oct 2022 13:43:58 +0000 en-US hourly 1 https://wordpress.org/?v=6.1.1 https://evotec.xyz/wp-content/uploads/2015/05/EvotecFavicon.ico Evotec https://evotec.xyz/ 32 32 Easy way to send emails using Microsoft Graph API (Office 365) with PowerShell https://evotec.xyz/easy-way-to-send-emails-using-microsoft-graph-api-office-365-with-powershell/ Sun, 09 Oct 2022 13:27:27 +0000 https://evotec.xyz/?p=17888 When you're using Office 365 and want to send an email, you have two choices SMTP or Microsoft Graph API, which is a "new" kid on the block. For some time, I've used Microsoft Graph exclusively to send emails in favor of SMTP as it's much easier to manage and generally works over HTTPS. If you type in google "Send email graph API PowerShell," you will get lots of hits as bloggers, and Microsoft has already covered this topic. It's even more critical than ever because Basic Authentication is deprecated in Office 365. To help out with the transition, Microsoft even released its PowerShell module. With Send-MgUserMail proposed as a way to send emails via Graph API, you will notice it's far from being easy & user-friendly. Over two years ago, I released a PowerShell module called Mailozaurr (some people may not like my modules' naming - but that's how I roll!). In a blog post, Mailozaurr – New mail toolkit (SMTP, IMAP, POP3) with support for oAuth 2.0 and GraphApi for PowerShell, I've shown a basic functionality on how to send emails using SMTP OAuth 2.0 or Graph API, which aims to be drag & drop replacement over Send-MailMessage and is supposed to be as simple as possible to send an email with a low effort and high readability. You can also read on sending emails using Graph API by Tony Redmond in his blog post Moving on from Send-MailMessage: Sending Email from PowerShell using the Graph API.

The post Easy way to send emails using Microsoft Graph API (Office 365) with PowerShell appeared first on Evotec.

]]>

When you're using Office 365 and want to send an email, you have two choices SMTP or Microsoft Graph API, which is a “new” kid on the block. For some time, I've used Microsoft Graph exclusively to send emails in favor of SMTP as it's much easier to manage and generally works over HTTPS. If you type in google “Send email graph API PowerShell,” you will get lots of hits as bloggers, and Microsoft has already covered this topic. Sending with Graph is even more critical than ever because Basic Authentication is deprecated in Office 365. To help out with the transition, Microsoft even released its PowerShell module. With Send-MgUserMail proposed as a way to send emails via Graph API, you will notice it's far from being easy & user-friendly. Over two years ago, I released a PowerShell module called Mailozaurr (some people may not like my modules' naming – but that's how I roll!). In a blog post, Mailozaurr – New mail toolkit (SMTP, IMAP, POP3) with support for oAuth 2.0 and GraphApi for PowerShell, I've shown a basic functionality on how to send emails using SMTP OAuth 2.0 or Graph API, which aims to be drag & drop replacement over Send-MailMessage and is supposed to be as simple as possible to send an email with a low effort and high readability. Today I will focus on sending emails with Graph API alone, as with recent updates, it's even more helpful than before.

While Microsoft has disabled basic authentication for POP, IMAP, and SMTP Auth, when it is not used, they are still allowing it to be used for SMTP Auth if you want to. However, as Microsoft says it themselves:

SMTP AUTH will still be available when Basic authentication is permanently disabled on October 1, 2022. The reason SMTP will still be available is that many multi-function devices such as printers and scanners can't be updated to use modern authentication. However, we strongly encourage customers to move away from using Basic authentication with SMTP AUTH when possible. Other options for sending authenticated mail include using alternative protocols, such as the Microsoft Graph API.

This means there's no urgent need to switch to GRAPH API or oAuth 2.0, as many legacy devices can't be easily switched to those. If you have time and means – why not change it now and improve your security?

Microsoft Graph API - The hard way to send emails!

But before we go to see Mailozaurr‘s way of sending emails, I wanted to show you what Microsoft wants you to use. If you know those ways, feel free to skip this section. Here is an example of sending an email with multiple recipients, the body of text, and information that email is not supposed to be saved in the Sent items folder.

Import-Module Microsoft.Graph.Users.Actions
$params = @{
  Message = @{
    Subject = "Meet for lunch?"
    Body = @{
      ContentType = "Text"
      Content = "The new cafeteria is open."
    }
    ToRecipients = @(
      @{
        EmailAddress = @{
          Address = "fannyd@contoso.onmicrosoft.com"
        }
      }
    )
    CcRecipients = @(
      @{
        EmailAddress = @{
          Address = "danas@contoso.onmicrosoft.com"
        }
      }
    )
  }
  SaveToSentItems = "false"
}
# A UPN can also be used as -UserId.
Send-MgUserMail -UserId $userId -BodyParameter $params

If you look above, after a while, you could understand how to send the basic email messages, but if you're just starting in PowerShell, this all seems to be very complicated when you want to achieve a quick and easy email functionality in your scripts. Another example shown by Microsoft is how to send emails with attachments using Microsoft Graph. However, if you look at the code below it's for the reader to find out what exactly codes ContentBytes means, where it comes from, and how attachment.txt from a folder on your drive somehow becomes ContentBytes. Noone mentions this anywhere. It's just left there for you to figure it out.

Import-Module Microsoft.Graph.Users.Actions
$params = @{
  Message = @{
    Subject = "Meet for lunch?"
    Body = @{
      ContentType = "Text"
      Content = "The new cafeteria is open."
    }
    ToRecipients = @(
      @{
        EmailAddress = @{
          Address = "meganb@contoso.onmicrosoft.com"
        }
      }
    )
    Attachments = @(
      @{
        "@odata.type" = "#microsoft.graph.fileAttachment"
        Name = "attachment.txt"
        ContentType = "text/plain"
        ContentBytes = "SGVsbG8gV29ybGQh"
      }
    )
  }
}
# A UPN can also be used as -UserId.
Send-MgUserMail -UserId $userId -BodyParameter $params

Before Microsoft released its PowerShell module, people had to resort to using Graph API via Invoke-RestMethod, where you have to build the message yourself. You had to find out the structure of the message, and what the options are, and then build a body out of it. The worst part is – one little mistake, and all you get is a Bad Request error message, and you're on your own.

$AccessToken = "**Access Token with Mail.Send permission***"
$ApiUrl = "https://graph.microsoft.com/v1.0/me/sendMail"
# Create JSON Body object
$Body = 
@"
{
"message" : {
"subject": "Test message",
"body" : {
"contentType": "Text",
"content": "This is test mail"
},
"toRecipients": [{"emailAddress" : { "address" : "to_address@domain.com" }}]
}
}
"@
Invoke-RestMethod -Headers @{Authorization = "Bearer $AccessToken"} -Uri $ApiUrl -Method Post -Body $Body -ContentType "application/json"

Microsoft Graph is a great product, but its packaging is far from great. If I compare the standard SMTP email process using Send-MailMessage to the proposed Graph API approach, it's no wonder people will choose an old technology over the new one! Even after working for a longer time with Graph API, I have to take a few deep breaths before trying to tackle available documentation. Let's look at two old examples for Send-MailMessage as shown on Microsoft Docs.

# send email message 
Send-MailMessage -From 'User01 <user01@fabrikam.com>' -To 'User02 <user02@fabrikam.com>' -Subject 'Test mail' -SmtpServer 'smtp.fabrikam.com'
# send email message with attachment and some body with priority of High and delivery notification
Send-MailMessage -From 'User01 <user01@fabrikam.com>' -To 'User02 <user02@fabrikam.com>', 'User03 <user03@fabrikam.com>' -Subject 'Sending the Attachment' -Body "Forgot to send the attachment. Sending now." -Attachments .\data.csv -Priority High -DeliveryNotificationOption OnSuccess, OnFailure -SmtpServer 'smtp.fabrikam.com'

The difference between the Graph API cmdlet and the standard SMTP cmdlet is enormous. Send-MailMessage is readable, simplified, and quick to adopt. Why not Graph API?

Microsoft Graph API - Authentication

By showing you how bad things can be and linking to other blog posts above, you will hopefully appreciate what Mailozaurr does for you when sending emails. One important thing to know when dealing with Graph API in Application mode is that before you can use it, you need to create an Application in Azure that allows you to Send Emails. Once created, granted permissions, and generated secrets, you should have ApplicationID, ClientID, and ClientSecret. Alternatively, ClientSecret can be replaced with Certificate, making the process more secure. Once you have that information, it's time to create the Credentials we will use in Send-EmailMessage. There are three ways to do it (that I will share – probably a few more exists):

  • Method 1 – Create credentials directly with ClientID, ClientSecret and DirectoryID. While easy and useful, it's not secure if you want to save it to use daily, as anyone having access to the script will have the ClientSecret, which is not very secure
$Credential = ConvertTo-GraphCredential -ClientID '0fb383f1-8bfe-4' -ClientSecret 'VKDM_2.' -DirectoryID 'ceb371f6-8'
  • Method 2 – Create credentials using ClientID, DirectoryID and with encrypted ClientSecret.  You can use the following command to encrypt credentials using built-in DPAPI encryption, and the encrypted secret will be copied to your clipboard.
ConvertTo-SecureString -String 'VKDM_2.eC2U' -AsPlainText -Force | ConvertFrom-SecureString | Set-Clipboard

Once you have an encrypted string, you can use it as part of the ClientSecretEncrypted parameter, which will make sure that the secret stays secret and will only be available for you or your account to use.

$Credential = ConvertTo-GraphCredential -ClientID '0fb383f1-8bfe-4c' -ClientSecretEncrypted 'This is encrypted string' -DirectoryID 'ceb376'

Remember that DPAPI encryption works by taking your username and computer name into the encryption process. The encrypted string won't be usable if you move the script to a different machine or if you will run it under a different account. Even if someone copies your script, the encrypted string will stay secure.

  • Method 3 – Use ClientID, CertificateThumbprint (an existing certificate in your certificate store is required), and DirectoryID.
Import-Module MSAL.PS -Force

$MsalTokenSplat = [ordered] @{
    ClientId              = '<Client Id>'
    CertificateThumbPrint = '<Certificate Thumbprint>'
    TenantId              = '<Tenant/Directory Id>'
    TenantDomain          = '<tenant domain name (xxxxx.onmicrosoft.com)>'
}
$MsalToken = Get-MsalToken @MsalTokenSplat
$Credential = ConvertTo-GraphCredential -MsalToken $MsalToken.AccessToken

This is the most secure method of using an additional Microsoft module to utilize a certificate as part of the authentication process. As with two other ways, ConvertTo-GraphCredential takes care of all translations to make them easy to use. Depending on your use case, different methods are available when dealing with the MSAL.PS module to authenticate. The important part is to provide an output of the MSAL token to the Convert function, which will do the rest for you.

Now we have the Credential variable created and ready, we're good to go and use Send-EmailMessage, which I mentioned earlier.

Microsoft Graph API - An easy way to send emails

When I wrote Mailozaurr, my main goal was to have a drag & drop replacement for Send-MailMessage because, as Microsoft mentions it, it's not recommended anymore.

The Send-MailMessage cmdlet is obsolete. This cmdlet does not guarantee secure connections to SMTP servers. While there is no immediate replacement available in PowerShell, we recommend you do not use Send-MailMessage. For more information, see Platform Compatibility note DE0005.

Of course, you should not read it, as it is suddenly wrong and something you need to stop using. If you still use the SMTP protocol to send emails and don't use oAuth 2.0 or Graph API, you can continue using Send-Mailmessage as it is.

But if you want choices, Mailozaurr, with its Send-EmailMessage, can make it very easy to transition. While writing that function, I've decided to support an easy switch from SMTP to SMTP with oAuth 2.0 to Microsoft Graph to SendGrid API (which is also supported). This gives me one tool to manage sending emails with minimal effort depending on the situation of my projects.

# Sending emails with Graph
Send-EmailMessage -From 'przemyslaw.klys@test.pl' -To 'kontakt@test.pl' -Credential $Credential -HTML $Body -Subject 'This is another test email' -Graph -Verbose -Priority Low -DoNotSaveToSentItems

Using the command above and the Graph switch, we notify Send-EmailMessage that this is a Graph-based email, and Mailozaurr will do proper message building itself. Are we sending attachments? As easy as using the Attachment parameter, which accepts an array of paths to files.

Send-EmailMessage -From 'my.account@test.pl' -To 'different.account@test.pl' -Credential $Credential -HTML $Body -Subject 'This is another test email 2' -Graph -Verbose -Priority Low -Attachment @("C:\test.svg","C:\otherfile.txt")

One thing to be aware of with attachments is that a standard message sent using Graph API can only have attachments up to 4MB in size. If you want to attach more significant attachments up to supported 150MB, you would need to create a draft message, create an upload session, upload attachments using another endpoint and finally send an email message. It's a fun exercise if you like that kind of fun. How do you deal with it in Send-EmailMessage? You don't. It does it for you. Once you provide a file larger than a threshold, it will create all that process for you and send an email without you having to do anything about it or noticing any difference. It also supports CC, BCC, and ReplyTo options. Delivery receipts and read receipts are also supported!

Send-EmailMessage -From 'przemyslaw.klys@test.pl' -To 'przemyslaw.klys@test.pl' -Credential $Credential -HTML $Body -Subject 'This is another test email' -Graph -Verbose -Priority Low -Attachment "C:\test.svg" -ReplyTo "kontakt@test.pl" -RequestReadReceipt -RequestDeliveryReceipt

And if you prefer splatting over very long lines, here's another approach to sending emails easily using Graph.

$sendEmailMessageSplat = @{
    From                   = 'przemyslaw.klys@test.pl'
    To                     = 'przemyslaw.klys@test.pl'
    Credential             = $Credential
    HTML                   = $Body
    Subject                = 'This is another test email'
    Graph                  = $true
    Verbose                = $true
    Priority               = 'Low'
    Attachment             = "C:\test.svg"
    ReplyTo                = "kontakt@test.pl"
    RequestReadReceipt     = $true
    RequestDeliveryReceipt = $true
}

Send-EmailMessage @sendEmailMessageSplat

Another cool feature of Send-EmailMessage is that, by default, it returns the status of a sent email message and the reason for failure if there was one. If you don't need this PowerShell object, you can use Suppress parameter to prevent this information from being exposed.

As you can see, with the help of Mailozaurr, sending emails with Graph API isn't any different than sending via standard SMTP. This should make it much easier to adopt secure sending of emails without the need to go thru a complicated process.

Firewall / Network Requirements

Sending email with Microsoft GRAPH no longer uses ports 25 or 587 and requires different network firewall ports. Graph API usesthe  following URLs:

  • https://login.microsoftonline.com
  • https://graph.microsoft.com

Of course, HTTPS port 443 over TCP needs to be open.

Sending emails with HTML content

Hopefully, you can now use Graph API to send emails easily using Mailozaurr, but one thing that often is more difficult than sending the email itself is creating HTML content. In the old days, you would need to play with HTML and CSS and hope it worked. Or you could install the PSWriteHTML PowerShell module and let the hard work be done for you. PSWriteHTML takes approach of never using HTML/CSS again to create HTML pages, but also emails. If you combine Mailozaurr and PSWriteHTML, you will see those two greatly simplify the process of sending nicely formatted emails. Would you believe the following email was created in 27 lines of PowerShell code?

PSWriteHTML creating Email Body

It contains standard, advanced, lists, tables, and images, and all that is done with ZERO HTML/CSS.

$Body = EmailBody {
    EmailText -Text "Hello Dear Reader," -LineBreak
    EmailText -Text "I would like to introduce you to ", "PSWriteHTML", " way of writting emails. " -Color None, SafetyOrange, None -FontWeight normal, bold, normal
    EmailText -Text "You can create standard text, or more advanced text by simply using provided parameters. " -Color Red
    EmailText -Text @(
        "Write your ", "text", " in ", "multiple ways: ", " colors", " or ", "fonts", " or ", "text transformations!"
    ) -Color Blue, Red, Yellow, GoldenBrown, SeaGreen, None, Green, None, SafetyOrange -FontWeight normal, bold, normal, bold, normal, normal, normal, normal, normal -LineBreak
    EmailText -Text "You can create lists: "
    EmailList {
        EmailListItem -Text "First item"
        EmailListItem -Text "Second item"
        EmailListItem -Text "Third item"
        EmailList {
            EmailListItem -Text "Nested item 1"
            EmailListItem -Text "Nested item 2"
        }
    } -Type Ordered -FontSize 15
    EmailText -Text "You can create tables: " -LineBreak
    EmailTable -DataTable (Get-Process | Select-Object -First 5 -Property Name, Id, PriorityClass, CPU, Product) -HideFooter
    EmailText -LineBreak
    EmailText -Text "Everything is customizable. " -Color California -FontStyle italic -TextDecoration underline
    EmailText -Text "You can even add images: " -LineBreak
    EmailImage -Source "https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" #-Width 200 -Height 200
    EmailText -Text "It's all just a command away. " -Color None -FontStyle normal -TextDecoration none
    EmailText -Text "You no longer have to use HTML/CSS, as it will be used for you!"
    EmailText -Text "With regards," -LineBreak
    EmailText -Text "Przemysław Kłys" -TextTransform capitalize -BackGroundColor Salmon
}

$Credential = ConvertTo-GraphCredential -ClientID $ClientID -ClientSecretEncrypted $EncryptedClientSecret -DirectoryID $DirectoryID

# sending email
$sendEmailMessageSplat = @{
    From                   = 'przemyslaw.klys@test.pl'
    To                     = 'przemyslaw.klys@test.pl'
    Credential             = $Credential
    HTML                   = $Body
    Subject                = 'This is another test email'
    Graph                  = $true
    Verbose                = $false
    Priority               = 'Low'
    Attachment             = "C:\test.svg"
    ReplyTo                = "kontakt@test.pl"
    RequestReadReceipt     = $false
    RequestDeliveryReceipt = $false
}

Send-EmailMessage @sendEmailMessageSplat

Using EmailBody, EmailText, EmailList, EmailListItem, EmailTable, and EmailImage functions, we could quickly build an email that is readable, nice looking, easy to modify, can contain variables as needed, and fully works with Microsoft Graph API and Send-EmailMessage. PSWriteHTML provides a few other experimental email features. Those are EmailLayout, EmailLayoutRow, and EmailLayoutColumn. Using those functions, you can display content next to each other quickly creating really advanced view for your emails. Take a look at example below

$Body = EmailBody {
    EmailText -Text "Hello Dear Reader," -LineBreak
    EmailText -Text "I would like to introduce you to ", "PSWriteHTML", " way of writting emails. " -Color None, SafetyOrange, None -FontWeight normal, bold, normal
    EmailText -Text "You can create standard text, or more advanced text by simply using provided parameters. " -Color Red
    EmailText -Text @(
        "Write your ", "text", " in ", "multiple ways: ", " colors", " or ", "fonts", " or ", "text transformations!"
    ) -Color Blue, Red, Yellow, GoldenBrown, SeaGreen, None, Green, None, SafetyOrange -FontWeight normal, bold, normal, bold, normal, normal, normal, normal, normal -LineBreak

    EmailText -Text "You can create lists, but also a multi-column layout with them: " -LineBreak

    EmailLayout {
        EmailLayoutRow {
            EmailLayoutColumn {
                EmailList {
                    EmailListItem -Text "First item"
                    EmailListItem -Text "Second item"
                    EmailListItem -Text "Third item"
                    EmailList {
                        EmailListItem -Text "Nested item 1"
                        EmailListItem -Text "Nested item 2"
                    }
                } -Type Ordered -FontSize 15
            }
            EmailLayoutColumn {
                EmailList {
                    EmailListItem -Text "First item - but on the right"
                    EmailListItem -Text "Second item - but on the right"
                    EmailListItem -Text "Third item"
                    EmailList {
                        EmailListItem -Text "Nested item 1"
                        EmailListItem -Text "Nested item 2"
                    }
                } -Type Ordered -FontSize 10 -Color RedBerry
            }
        }
        EmailLayoutRow {
            EmailText -Text "Lets see how you can have multiple logos next to each other" -LineBreak
        }
        EmailLayoutRow {
            EmailLayoutColumn {
                EmailImage -Source "https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" -Width 150
            } -PaddingTop 30
            EmailLayoutColumn {
                EmailImage -Source "https://evotec.pl/wp-content/uploads/2015/05/Logo-evotec-012.png" -Width 150
            } -PaddingTop 30
            EmailLayoutColumn {
                EmailImage -Source "https://upload.wikimedia.org/wikipedia/commons/9/96/Microsoft_logo_%282012%29.svg" -Width 150
            } -PaddingTop 30
            EmailLayoutColumn {
                EmailImage -Source "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fe/Pepsi_logo_%282014%29.svg/2560px-Pepsi_logo_%282014%29.svg.png" -Width 150
            } -PaddingTop 30
        }
        EmailLayoutRow {
            EmailText -LineBreak
            EmailText -LineBreak
        }
        EmailLayoutRow {
            EmailText -Text "You can create tables: " -LineBreak
            EmailTable -DataTable (Get-Process | Select-Object -First 5 -Property Name, Id, PriorityClass, CPU, Product) -HideFooter
            EmailText -LineBreak
            EmailText -Text "Everything is customizable. " -Color California -FontStyle italic -TextDecoration underline
            EmailText -Text "You can even add images: " -LineBreak
            EmailImage -Source "https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" #-Width 200 -Height 200
            EmailText -Text "It's all just a command away. " -Color None -FontStyle normal -TextDecoration none
            EmailText -Text "You no longer have to use HTML/CSS, as it will be used for you!"
            EmailText -Text "With regards," -LineBreak
            EmailText -Text "Przemysław Kłys" -TextTransform capitalize -BackGroundColor Salmon
        }
    }
}

Save-HTML -FilePath "test.html" -ShowHTML -HTML $Body

Notice how using EmailLayoutRow/EmailLayoutColumn, I put two lists and four images next to each other. It's still in the experimental stage but feel free to play and report feedback on Github. Using the Save-HTML function helps test HTML in a browser rather than sending emails all the time, but be careful that what you see in the browser often may look different in email clients. Always test before production rollout!

Installing PSTeams PowerShell Module

Finally, we've arrived at the stage where you ask the question – how can I get Mailozaurr or/and PSWriteHTML? How do you install it? The easiest and most optimal way is to use PowerShellGallery. This will get you up and running in no time. You're done and when there is an update, just run Update-Module and it will do the rest!

Install-Module Mailozaurr -Force -Verbose
Install-Module PSWriteHTML -Force -Verbose

However, if you're into code – want to see how everything is done, you can use GitHub sources. Please remember that the PowerShellGallery version is optimized and better for production use. If you see any issues, bugs, or features that are missing, please make sure to submit them on GitHub.

  • Code for Mailozaurr is published as a module on PowerShellGallery
  • Issues for Mailozaurr should be reported on GitHub
  • Code for Mailozaurr is published on GitHub
  • Code for PSWriteHTML is published as a module on PowerShellGallery
  • Issues for PSWriteHTML should be reported on GitHub
  • Code for PSWriteHTML is published on GitHub

The post Easy way to send emails using Microsoft Graph API (Office 365) with PowerShell appeared first on Evotec.

]]>
Adaptive Cards with Tables and Linebreaks in Microsoft Teams https://evotec.xyz/adaptive-cards-with-tables-and-linebreaks-in-microsoft-teams/ Sun, 21 Aug 2022 16:07:01 +0000 https://evotec.xyz/?p=17842 PSTeams is a PowerShell module that helps simplify sending notifications to Microsoft Teams via Incoming webhooks. It's easy to use and doesn't require playing with JSON. Since version 2.0, it started to support Adaptive Cards; in version 2.1, I've added the ability to mention people. Today I'm introducing an easy way to send data as a table and a quick way to add a line break.

The post Adaptive Cards with Tables and Linebreaks in Microsoft Teams appeared first on Evotec.

]]>

PSTeams is a PowerShell module that helps simplify sending notifications to Microsoft Teams via Incoming webhooks. It's easy to use and doesn't require playing with JSON. Since version 2.0, it started to support Adaptive Cards; in version 2.1, I've added the ability to mention people. Today I'm introducing an easy way to send data as a table and a quick way to add a line break.

Creating Adaptive Card Tables & Line breaks

If you have ever created Adaptive Cards by hand with JSON, you know it's far from easy. You need to know what goes with what and what the options are. Colors are predefined (and not something you would expect), weight has specific values, and so on. PSTeams are there to help. Before introducing Adaptive Tables and Line breaks, you need to know that there is no native way to build tables, so one has to create them yourself using Adaptive Column, Adaptive ColumnSet, and Adaptive TextBlock. I've just made sure to automate it enough, so it's much easier to use in PowerShell than building it manually.

$URL = "incoming webhook"

# Lets prepare dummmy object array with few elements
$Objects = @(
    [PSCustomObject] @{
        Test  = 123
        Test2 = "Tes1t"
    }
    [PSCustomObject] @{
        Test  = 456
        Test2 = "Test2"
    }
    [PSCustomObject] @{
        Test  = 789
        Test2 = "Test3"
    }
)

# Different dummy object array with few elements as ordered dictionary or hashtable
$ObjectsHashes = @(
    [ordered] @{
        Test  = 123
        Test2 = "Tes1t"
    }
    [ordered] @{
        Test  = 456
        Test2 = "Test2"
    }
    [ordered] @{
        Test  = 789
        Test2 = "Test3"
    }
)

# Lets create a new adaptive card
$Card = New-AdaptiveCard {
    # lets add some text, table and line breaks
    New-AdaptiveTextBlock -Size 'Medium' -Weight Bolder -Text 'Table usage with PSCustomObject 🔥' -Wrap

    New-AdaptiveTable -DataTable $Objects

    New-AdaptiveLineBreak

    New-AdaptiveTextBlock -Size 'Medium' -Weight Bolder -Text 'Table usage with OrderedDictionary 🤷‍♂️' -Wrap

    New-AdaptiveTable -DataTable $ObjectsHashes

    New-AdaptiveLineBreak

    New-AdaptiveTextBlock -Size 'Medium' -Weight Bolder -Text 'Table usage with display as PSCustomObject ❤' -Wrap

    New-AdaptiveTable -DataTable $ObjectsHashes -DictionaryAsCustomObject -HeaderColor Attention

    New-AdaptiveTextBlock -Text 'Different example' -Size Large -Subtle -Spacing ExtraLarge

    New-AdaptiveLineBreak

    # and here we mix it with some sample from Adaptive cards
    New-AdaptiveContainer {
        New-AdaptiveColumnSet {
            New-AdaptiveColumn {
                New-AdaptiveImage -Url "https://adaptivecards.io/content/cats/3.png" -Size Medium -AlternateText "Shades cat team emblem" -HorizontalAlignment Center
                New-AdaptiveTextBlock -Weight Bolder -Text 'SHADES' -HorizontalAlignment Center
            } -Width Auto
            New-AdaptiveColumn {
                New-AdaptiveTextBlock -Text "Sat, Aug 31, 2019" -HorizontalAlignment Center -Wrap
                New-AdaptiveTextBlock -Text "Final" -Spacing None -HorizontalAlignment Center
                New-AdaptiveTextBlock -Text "45 - 7" -HorizontalAlignment Center -Size ExtraLarge
            } -Width Stretch -Separator -Spacing Medium
            New-AdaptiveColumn {
                New-AdaptiveImage -Url "https://adaptivecards.io/content/cats/2.png" -Size Medium -HorizontalAlignment Center -AlternateText "Skins cat team emblem"
                New-AdaptiveTextBlock -Weight Bolder -Text 'SKINS' -HorizontalAlignment Center
            } -Width Auto -Separator -Spacing Medium
        }
    }

    # and lets convert Get-Process into Adaptive card
    New-AdaptiveTextBlock -Text 'Lets convert Get-Process into Adaptive Table' -Size Large -Subtle -Spacing ExtraLarge
    New-AdaptiveLineBreak

    $TableData = Get-Process | Select-Object -First 5 -Property Name, Id, CompanyName, CPU, FileName
    New-AdaptiveTable -DataTable $TableData -HeaderHorizontalAlignment Center -HorizontalAlignment Center -HeaderColor Good -Size Small

} -Uri $URL -FullWidth -ReturnJson

$Card | ConvertFrom-Json | ConvertTo-Json -Depth 20

As you see above, there are about 90 lines of code, of which 30 is the definition for PowerShell objects and 60 creation of Adaptive Card, but if you look at what was produced, you get over 800 lines of JSON file. Can you imagine doing this by hand?! What is the result?

The above shows that you now have New-AdaptiveTable that provides an easy way to send the whole table to Adaptive Card. You, of course, need to be aware of limits such as width or height. You won't be able to suddenly send a table with 60 columns and expect it to work. This is why I've picked specific columns from Get-Process for demonstration purposes. I've also added New-AdaptiveLineBreak, which adds an empty line as the name suggests. This can be useful if you send more than a few lines of text, so it's a bit more readable. Tables have multiple parameters available, primarily for deciding colors, alignment, spacing, or wrapping for headers or content if you don't like the defaults.

Notice that I've also added the ReturnJSON parameter to New-AdaptiveCard. By default, you don't need this parameter, but it is useful when debugging what was sent to Teams. Also, if you don't provide an URL, whether you provide ReturnJSON or not, JSON will be returned anyway.

Installing PSTeams PowerShell Module

How do you install it? The easiest and most optimal way is to use PowerShellGallery. This will get you up and running in no time. Whenever there is an update, just run Update-Module, and you're done.

Install-Module PSTeams
# Update-Module PSTeams

However, if you're into code – want to see how everything is done, you can use GitHub sources. Please remember that the PowerShellGallery version is optimized and better for production use. If you see any issues, bugs, or features that are missing, please make sure to submit them on GitHub.

Building Adaptive Card using JSON

For the sake of completeness of this blog post to create the same Adaptive Card using just JSON you would need to do this:

{
    "type": "message",
    "attachments": [
        {
            "contentType": "application/vnd.microsoft.card.adaptive",
            "content": {
                "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
                "type": "AdaptiveCard",
                "version": "1.2",
                "body": [
                    {
                        "type": "TextBlock",
                        "text": "Table usage with PSCustomObject 🔥",
                        "size": "Medium",
                        "weight": "Bolder",
                        "highlight": "False",
                        "italic": "False",
                        "strikeThrough": "False",
                        "wrap": true
                    },
                    {
                        "type": "ColumnSet",
                        "columns": [
                            {
                                "type": "Column",
                                "width": "stretch",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "text": "Test",
                                        "weight": "Bolder",
                                        "color": "Accent",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False"
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "123",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "456",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "789",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    }
                                ]
                            },
                            {
                                "type": "Column",
                                "width": "stretch",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "text": "Test2",
                                        "weight": "Bolder",
                                        "color": "Accent",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False"
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Tes1t",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Test2",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Test3",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    }
                                ]
                            }
                        ]
                    },
                    {
                        "type": "TextBlock",
                        "text": "\n",
                        "highlight": "False",
                        "italic": "False",
                        "strikeThrough": "False"
                    },
                    {
                        "type": "TextBlock",
                        "text": "Table usage with OrderedDictionary 🤷‍♂️",
                        "size": "Medium",
                        "weight": "Bolder",
                        "highlight": "False",
                        "italic": "False",
                        "strikeThrough": "False",
                        "wrap": true
                    },
                    {
                        "type": "ColumnSet",
                        "columns": [
                            {
                                "type": "Column",
                                "width": "stretch",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "text": "Name",
                                        "weight": "Bolder",
                                        "color": "Accent",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False"
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Test",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Test2",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Test",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Test2",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Test",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Test2",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    }
                                ]
                            },
                            {
                                "type": "Column",
                                "width": "stretch",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "text": "Value",
                                        "weight": "Bolder",
                                        "color": "Accent",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False"
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "123",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Tes1t",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "456",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Test2",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "789",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Test3",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    }
                                ]
                            }
                        ]
                    },
                    {
                        "type": "TextBlock",
                        "text": "\n",
                        "highlight": "False",
                        "italic": "False",
                        "strikeThrough": "False"
                    },
                    {
                        "type": "TextBlock",
                        "text": "Table usage with display as PSCustomObject ❤",
                        "size": "Medium",
                        "weight": "Bolder",
                        "highlight": "False",
                        "italic": "False",
                        "strikeThrough": "False",
                        "wrap": true
                    },
                    {
                        "type": "ColumnSet",
                        "columns": [
                            {
                                "type": "Column",
                                "width": "stretch",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "text": "Test",
                                        "weight": "Bolder",
                                        "color": "Attention",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False"
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "123",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "456",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "789",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    }
                                ]
                            },
                            {
                                "type": "Column",
                                "width": "stretch",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "text": "Test2",
                                        "weight": "Bolder",
                                        "color": "Attention",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False"
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Tes1t",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Test2",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "Test3",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    }
                                ]
                            }
                        ]
                    },
                    {
                        "type": "TextBlock",
                        "text": "Different example",
                        "spacing": "ExtraLarge",
                        "size": "Large",
                        "highlight": "False",
                        "italic": "False",
                        "strikeThrough": "False",
                        "isSubtle": true
                    },
                    {
                        "type": "TextBlock",
                        "text": "\n",
                        "highlight": "False",
                        "italic": "False",
                        "strikeThrough": "False"
                    },
                    {
                        "type": "Container",
                        "items": [
                            {
                                "type": "ColumnSet",
                                "columns": [
                                    {
                                        "type": "Column",
                                        "width": "auto",
                                        "items": [
                                            {
                                                "type": "Image",
                                                "url": "https://adaptivecards.io/content/cats/3.png",
                                                "size": "Medium",
                                                "alt": "Shades cat team emblem",
                                                "horizontalAlignment": "Center"
                                            },
                                            {
                                                "type": "TextBlock",
                                                "text": "SHADES",
                                                "horizontalAlignment": "Center",
                                                "weight": "Bolder",
                                                "highlight": "False",
                                                "italic": "False",
                                                "strikeThrough": "False"
                                            }
                                        ]
                                    },
                                    {
                                        "type": "Column",
                                        "width": "stretch",
                                        "items": [
                                            {
                                                "type": "TextBlock",
                                                "text": "Sat, Aug 31, 2019",
                                                "horizontalAlignment": "Center",
                                                "highlight": "False",
                                                "italic": "False",
                                                "strikeThrough": "False",
                                                "wrap": true
                                            },
                                            {
                                                "type": "TextBlock",
                                                "text": "Final",
                                                "spacing": "None",
                                                "horizontalAlignment": "Center",
                                                "highlight": "False",
                                                "italic": "False",
                                                "strikeThrough": "False"
                                            },
                                            {
                                                "type": "TextBlock",
                                                "text": "45 - 7",
                                                "horizontalAlignment": "Center",
                                                "size": "ExtraLarge",
                                                "highlight": "False",
                                                "italic": "False",
                                                "strikeThrough": "False"
                                            }
                                        ],
                                        "spacing": "Medium",
                                        "separator": true
                                    },
                                    {
                                        "type": "Column",
                                        "width": "auto",
                                        "items": [
                                            {
                                                "type": "Image",
                                                "url": "https://adaptivecards.io/content/cats/2.png",
                                                "size": "Medium",
                                                "alt": "Skins cat team emblem",
                                                "horizontalAlignment": "Center"
                                            },
                                            {
                                                "type": "TextBlock",
                                                "text": "SKINS",
                                                "horizontalAlignment": "Center",
                                                "weight": "Bolder",
                                                "highlight": "False",
                                                "italic": "False",
                                                "strikeThrough": "False"
                                            }
                                        ],
                                        "spacing": "Medium",
                                        "separator": true
                                    }
                                ]
                            }
                        ]
                    },
                    {
                        "type": "TextBlock",
                        "text": "Lets convert Get-Process into Adaptive Table",
                        "spacing": "ExtraLarge",
                        "size": "Large",
                        "highlight": "False",
                        "italic": "False",
                        "strikeThrough": "False",
                        "isSubtle": true
                    },
                    {
                        "type": "TextBlock",
                        "text": "\n",
                        "highlight": "False",
                        "italic": "False",
                        "strikeThrough": "False"
                    },
                    {
                        "type": "ColumnSet",
                        "columns": [
                            {
                                "type": "Column",
                                "width": "stretch",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "text": "Name",
                                        "horizontalAlignment": "Center",
                                        "weight": "Bolder",
                                        "color": "Good",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False"
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "1Password",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "1Password",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "1Password",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "1Password",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "1Password-BrowserSupport",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    }
                                ]
                            },
                            {
                                "type": "Column",
                                "width": "stretch",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "text": "Id",
                                        "horizontalAlignment": "Center",
                                        "weight": "Bolder",
                                        "color": "Good",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False"
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "10168",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "11280",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "12180",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "18588",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "17224",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    }
                                ]
                            },
                            {
                                "type": "Column",
                                "width": "stretch",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "text": "CompanyName",
                                        "horizontalAlignment": "Center",
                                        "weight": "Bolder",
                                        "color": "Good",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False"
                                    },
                                    {
                                        "type": "TextBlock",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    }
                                ]
                            },
                            {
                                "type": "Column",
                                "width": "stretch",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "text": "CPU",
                                        "horizontalAlignment": "Center",
                                        "weight": "Bolder",
                                        "color": "Good",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False"
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "7.03125",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "1.40625",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "1038.75",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "3.765625",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": "0.828125",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    }
                                ]
                            },
                            {
                                "type": "Column",
                                "width": "stretch",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "text": "FileName",
                                        "horizontalAlignment": "Center",
                                        "weight": "Bolder",
                                        "color": "Good",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False"
                                    },
                                    {
                                        "type": "TextBlock",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    },
                                    {
                                        "type": "TextBlock",
                                        "horizontalAlignment": "Center",
                                        "size": "Small",
                                        "highlight": "False",
                                        "italic": "False",
                                        "strikeThrough": "False",
                                        "separator": true
                                    }
                                ]
                            }
                        ]
                    }
                ],
                "msteams": {
                    "width": "Full"
                }
            }
        }
    ]
}

The post Adaptive Cards with Tables and Linebreaks in Microsoft Teams appeared first on Evotec.

]]>
Working with VirusTotal from PowerShell https://evotec.xyz/working-with-virustotal-from-powershell/ Wed, 10 Aug 2022 18:25:07 +0000 https://evotec.xyz/?p=17828 Virus Total is an excellent service. It's a single place where hundreds of antivirus engines can verify if the file, URL, domain name, or IP Address is trusted or not. Of course, it's not a silver bullet, but it brings tremendous value, and I often verify files I download before executing. Since I release a lot of new or updated PowerShell modules on a weekly/monthly basis, I thought it would be great to send newly released versions straight to Virus Total so I can have them checked before anyone executes them. I also hope to prevent false positives from some antivirus vendors that may tag my modules as malware because they haven't seen the DLL or PowerShell module in this form before. I've seen it happen to DBATools, so why not try and push my modules before users even use them?

The post Working with VirusTotal from PowerShell appeared first on Evotec.

]]>

Virus Total is an excellent service. It's a single place where hundreds of antivirus engines can verify if the file, URL, domain name, or IP Address is trusted or not. Of course, it's not a silver bullet, but it brings tremendous value, and I often verify files I download before executing. Since I release a lot of new or updated PowerShell modules on a weekly/monthly basis, I thought it would be great to send newly released versions straight to Virus Total so I can have them checked before anyone executes them. I also hope to prevent false positives from some antivirus vendors that may tag my modules as malware because they haven't seen the DLL or PowerShell module in this form before. I've seen it happen to DBATools, so why not try and push my modules before users even use them?

I've created a small PowerShell module, VirusTotalAnalyzer, which provides two simple commands that connect Virus Total using their Rest API v3. This is the newest API that is available. The module should work fine on PowerShell 5.1 and PowerShell 7+ and should work cross-platform, but I've not checked that yet.

Querying Virus Total for existing data with PowerShell

VirusTotalAnalyzer provides the Get-VirusReport function, which has five ways of requesting data from Virus Total. Those are by Hash, File, DomainName, IPAddress, or Search string. An important parameter of Get-VirusReport is the ApiKey parameter. You will need to have APIKey before you can use this module. Fortunately, ApiKey is available for free once you register to Virus Total. Once you have it the usage is as shown below

$VTApi = "APIKey"

$T1 = Get-VirusReport -ApiKey $VTApi -Hash 'BFF77EECBB2F7DA25ECBC9D9673E5DC1DB68DCC68FD76D006E836F9AC61C547E'
$T2 = Get-VirusReport -ApiKey $VTApi -File "$PSScriptRoot\Submisions\TestFile.txt"
$T3 = Get-VirusReport -ApiKey $VTApi -DomainName 'evotec.xyz'
$T4 = Get-VirusReport -ApiKey $VTApi -IPAddress '1.1.1.1'
$T5 = Get-VirusReport -ApiKey $VTApi -Search "https://evotec.xyz"

Each query returns similar data. It returns it the way Rest API returns it, and I'm not making any changes to the output. Data has multiple levels with different attributes available to you.

The reports are very detailed and deep. You are given many details depending on what has been scanned. For example, when scanning the PSM1 file, the object returns powershell_info attributes.

While the module is small, it provides enough functionality to help automate some of the processes I want my modules to go through. While other PowerShell modules deal with Virus Total, they don't use v3 API (AFYIK), and I always like to reinvent the wheel – why not?

Sending file / url to Virus Total using PowerShell

Using the New-VirusScan function, you can send files or URLs to be verified by Virus Total. Keep in mind that this is not an immediate process, and it takes 60 seconds to get information back from Virus Total. So you can either submit and verify your scan by using a hash or analysis ID provided as part of the New-VirusScan function.

Import-Module VirusTotalAnalyzer -Force

$VTApi = "APIKey"

# Submit file to scan
$Output = New-VirusScan -ApiKey $VTApi -File "$PSScriptRoot\Submisions\TestFile.txt"
$Output | Format-List

Start-Sleep -Seconds 120

# Since the output will return scan ID we can use it to get the report
$OutputScan = Get-VirusReport -ApiKey $VTApi -AnalysisId $Output.data.id
$OutputScan | Format-List
$OutputScan.Meta | Format-List
$OutputScan.Data | Format-List

New-VirusScan also provides an option to rescan file/hash. I didn't want to create a separate function for this functionality. You can rescan content that is already existing in Virus Total, forcing Virus Total to reassess whatever it has one more time with current definitions.

Import-Module VirusTotalAnalyzer -Force

$VTApi = "APIKey"

# Submit file hash to rescan from existing file (doesn't sends the file)
$Output = New-VirusScan -ApiKey $VTApi -FileHash "$PSScriptRoot\Submisions\TestFile.txt"
$Output | Format-List

# Submit hash to rescan
$Output = New-VirusScan -ApiKey $VTApi -Hash "ThisHashHasToExistsOnVirusTotal"
$Output | Format-List

Installing VirusTotalAnalyzer

Those functions are part of the VirusTotalAnalyzer module and all you need to do to install it is:

Install-Module VirusTotalAnalyzer -Force -Verbose

The module can also be installed without administrative privileges on the workstation using the Scope parameter.

Install-Module VirusTotalAnalyzer -Scope CurrentUser

For sources, reporting issues, or feature requests, as always, visit GitHub. All my projects are hosted on it, and it's the preferred method of providing support.

The post Working with VirusTotal from PowerShell appeared first on Evotec.

]]>
Reporting group membership for critical Active Directory groups https://evotec.xyz/reporting-group-membership-for-critical-active-directory-groups/ Sun, 07 Aug 2022 11:57:28 +0000 https://evotec.xyz/?p=17800 I work a lot with Active Directory-related tasks. One of the tasks is to know the group membership of critical Active Directory Groups such as Domain Admins, Enterprise Admins, Schema Admins, Event Log Readers, and a few others that are a bit less known. As I did it, I got bored of typing the group names repeatedly and decided that enough was enough and there must be an easier way for me to do that.

The post Reporting group membership for critical Active Directory groups appeared first on Evotec.

]]>

I work a lot with Active Directory-related tasks. One of the tasks is to know the group membership of critical Active Directory Groups such as Domain Admins, Enterprise Admins, Schema Admins, Event Log Readers, and a few others that are a bit less known. As I did it, I got bored of typing the group names repeatedly and decided that enough was enough and there must be an easier way for me to do that.

Getting group membership from Critical AD Groups

To do my automation, I've utilized functions shown in Visually display Active Directory nested Group Membership using PowerShell that is part of ADEssentials PowerShell Module. I've created a function called Show-WinADGroupCritical, which generates easy to use preview of all built-in critical groups in Active Directory.

Show-WinADGroupCritical -Verbose

It may take a while to generate, depending on how big your AD is and what sort of nightmare you hold in those groups.

By default, the function auto-detects Active Directory Forest and scans all the domains and all the groups. Since those critical groups should be mostly empty, the default settings provided by the function should work just fine for everyone. Unfortunately, I often meet domains with not-so-clean states and recently had to add the SkipDiagram parameter, which allows me to skip generating diagrams if I meet a group with, say, 1500 nested groups. Unfortunately, with a large number of groups, the diagrams with that many groups become unreadable and challenging to load in HTML. So if you ever just want to find out nested group membership and all additional details without the need for diagrams or because the diagrams are slowing you down – you can use the SkipDiagram option just to get the meat you require.

Show-WinADGroupCritical -Verbose -FilePath $Env:UserProfile\Desktop\GroupReport.html -Online -SkipDiagram

I've used the Verbose parameter, which shows what is happening in the background, and FilePath to save the report into the specific path.

There's also a parameter called Summary, which bundles all groups into two diagrams and can show you the whole picture of all your critical groups and how they are connected (if at all).

Show-WinADGroupCritical -Verbose -FilePath $Env:UserProfile\Desktop\GroupReport.html -Online -Summary

Remember that you can also pick and choose groups you want to display from the defined critical groups. Just in case you're not interested in the full scope of your Forest.

Keep in mind that in the best-case scenario, those groups should be mostly empty with very few actual users in them, and primarily Administrative at that. If you have many users, nested groups, and service accounts, you may want to investigate why and if that's necessary.

Available function options

get-help -Detailed Show-WinADGroupCritical

NAME
    Show-WinADGroupCritical

SYNOPSIS
    Command to gather nested group membership from default critical groups in the Active Directory.


SYNTAX
    Show-WinADGroupCritical [[-GroupName] <String[]>] [[-FilePath] <String>] [[-HideAppliesTo] <String>] [-HideComputers]
    [-HideUsers] [-HideOther] [-Online] [-HideHTML] [-DisableBuiltinConditions] [-AdditionalStatistics] [-SkipDiagram] [-Summary]
    [<CommonParameters>]


DESCRIPTION
    Command to gather nested group membership from default critical groups in the Active Directory.
    This command will show data in table and diagrams in HTML format.


PARAMETERS
    -GroupName <String[]>
        Group Name or Names to search for from provided list. If skipped all groups will be checked.

    -FilePath <String>
        Path to HTML file where it's saved. If not given temporary path is used

    -HideAppliesTo <String>
        Allows to define to which diagram HideComputers,HideUsers,HideOther applies to

    -HideComputers [<SwitchParameter>]
        Hide computers from diagrams - useful for performance reasons

    -HideUsers [<SwitchParameter>]
        Hide users from diagrams - useful for performance reasons

    -HideOther [<SwitchParameter>]
        Hide other objects from diagrams - useful for performance reasons

    -Online [<SwitchParameter>]
        Forces use of online CDN for JavaScript/CSS which makes the file smaller. Default - use offline.

    -HideHTML [<SwitchParameter>]
        Prevents HTML output from being displayed in browser after generation is done

    -DisableBuiltinConditions [<SwitchParameter>]
        Disables table coloring allowing user to define it's own conditions

    -AdditionalStatistics [<SwitchParameter>]
        Adds additional data to Self object. It includes count for NestingMax, NestingGroup, NestingGroupSecurity,
        NestingGroupDistribution. It allows for easy filtering where we expect security groups only when there are nested
        distribution groups.

    -SkipDiagram [<SwitchParameter>]
        Skips diagram generation and only displays table. Useful if the diagram can't handle amount of data or if the diagrams
        are not nessecary.

    -Summary [<SwitchParameter>]
        Adds additional tab with all groups together on two diagrams

    <CommonParameters>
        This cmdlet supports the common parameters: Verbose, Debug,
        ErrorAction, ErrorVariable, WarningAction, WarningVariable,
        OutBuffer, PipelineVariable, and OutVariable. For more information, see
        about_CommonParameters (https:/go.microsoft.com/fwlink/?LinkID=113216).

Installing ADEssentials

Those cmdlets are part of the ADEssentials module that I've been enhancing for some time now. All you need to do, to install it is:

Install-Module ADEssentials -Force -Verbose

Many commands in the ADEssentials module require RSAT (ActiveDirectory/GroupPolicy) to be present to work. Some cmdlets are ADSI based, so they don't need RSAT to work, but others from ADEssentials do.

When you install the module, it will also install PSWriteHTML and PSEventViewer. The first one is required for displaying output in HTML (tables/diagrams). The other one is a wrapper around Get-WinEvent and is used by some of the commands available within ADEssentials. Install-Module will do all that installation for you without doing anything except for the RSAT requirement, but if you're AD Admin, you should already have those up and ready to use. If you don't have admin rights on your workstation, it's still possible to use this module.

Install-Module ADEssentials -Scope CurrentUser

As those cmdlets described above are read-only, they don't require any rights in AD. I've been digesting my production environments using my standard ID. For sources, reporting issues, or feature requests, as always, visit GitHub. All my projects are hosted on it, and it's preferred method of providing support.

The post Reporting group membership for critical Active Directory groups appeared first on Evotec.

]]>
Finding duplicate DNS records by IP Address using PowerShell https://evotec.xyz/finding-duplicate-dns-records-by-ip-adress-using-powershell/ Tue, 26 Jul 2022 17:23:53 +0000 https://evotec.xyz/?p=17770 In my earlier blog post, I showed you a way to find duplicate DNS entries using PowerShell, but the focus was on finding duplicate entries based on hostname. But what if you would like to find duplicate entries based on IP Addresses? This was the question I was asked on Reddit, and I thought it was a legitimate request, so today's focus will be on transposing table output from earlier functions to present data differently. 

The post Finding duplicate DNS records by IP Address using PowerShell appeared first on Evotec.

]]>

In my earlier blog post, I showed you a way to find duplicate DNS entries using PowerShell, but the focus was on finding duplicate entries based on hostname. But what if you would like to find duplicate entries based on IP Addresses? This was the question I was asked on Reddit, and I thought it was a legitimate request, so today's focus will be on transposing table output from earlier functions to present data differently.

Duplicate DNS Entries by IP Address

Just like other function from the earlier blog Get-WinDNSRecords I've added this new command to the ADEssentials module as well. Its usage is simple. As long as you have RSAT tools for AD and DNS, it will autodetect the required settings and display results.

Get-WinDNSIPAddresses | ft

As with the earlier command, this one also has additional parameters. You can use Prettify to make the table display it correctly for CSV/HTML output. You can also use IncludeDetails to provide WhenCreated/WhenChanged properties. As before, the Count column contains information for easy sorting and finding duplicate entries.

Get-WinDNSIPAddresses -Prettify -IncludeDetails | ft

By default, this command gets all the zones, but one can modify its output using IncludeZone, and ExcludeZone depending on how large your AD is and what you are interested in. Finally, there is also the IncludeDNSRecords switch which provides the ability to work with complete DNS records instead of their text form.

NAME
    Get-WinDNSIPAddresses

SYNOPSIS
    Gets all the DNS records from all the zones within a forest sorted by IPAddress


SYNTAX
    Get-WinDNSIPAddresses [[-IncludeZone] <String[]>] [[-ExcludeZone] <String[]>] [-IncludeDetails] [-Prettify] [-IncludeDNSRecords] [-AsHashtable] [<CommonParameters>]


DESCRIPTION
    Gets all the DNS records from all the zones within a forest sorted by IPAddress


PARAMETERS
    -IncludeZone <String[]>
        Limit the output of DNS records to specific zones

    -ExcludeZone <String[]>
        Limit the output of dNS records to only zones not in the exclude list

    -IncludeDetails [<SwitchParameter>]
        Adds additional information such as creation time, changed time

    -Prettify [<SwitchParameter>]
        Converts arrays into strings connected with comma

    -IncludeDNSRecords [<SwitchParameter>]
        Include full DNS records just in case one would like to further process them

    -AsHashtable [<SwitchParameter>]
        Outputs the results as a hashtable instead of an array

    <CommonParameters>
        This cmdlet supports the common parameters: Verbose, Debug,
        ErrorAction, ErrorVariable, WarningAction, WarningVariable,
        OutBuffer, PipelineVariable, and OutVariable. For more information, see
        about_CommonParameters (https:/go.microsoft.com/fwlink/?LinkID=113216).

    -------------------------- EXAMPLE 1 --------------------------

    PS C:\>Get-WinDNSIPAddresses | Format-Table *






    -------------------------- EXAMPLE 2 --------------------------

    PS C:\>Get-WinDNSIPAddresses -Prettify | Format-Table *






    -------------------------- EXAMPLE 3 --------------------------

    PS C:\>Get-WinDNSIPAddresses -Prettify -IncludeDetails -IncludeDNSRecords | Format-Table *






REMARKS
    To see the examples, type: "get-help Get-WinDNSIPAddresses -examples".
    For more information, type: "get-help Get-WinDNSIPAddresses -detailed".
    For technical information, type: "get-help Get-WinDNSIPAddresses -full".
Get-WinDNSIPAddresses -Prettify -IncludeDetails | Out-HtmlView -Filtering

If you're using PSWriteHTML, you can quickly pipe Get-WinDNSIPAddresses to Out-HtmlView and have filters and all other goodies along with Excel, CSV, and PDF export ready. If you're into something cooler, you can get nice little HTML that will make it easy for your eyes to pick what you need.

# Install module should be only done once, unless you want to update to newest version
Install-Module PSWriteHTML -Force -Verbose -Scope CurrentUser
# import module should be done every time you want to use it, although PowerShell autoloads most PowerShell modules
Import-Module PSWriteHTML -Force

# Gather data
$DNSByName = Get-WinDNSRecords -Prettify -IncludeDetails
$DNSByIP = Get-WinDNSIPAddresses -Prettify -IncludeDetails

# Create HTML 🙂
New-HTML {
    New-HTMLTab -Name "DNS by Name" {
        New-HTMLTable -DataTable $DNSByName -Filtering {
            New-HTMLTableCondition -Name 'Count' -ComparisonType number -Value 1 -BackgroundColor LightGreen
            New-HTMLTableCondition -Name 'Count' -ComparisonType number -Value 1 -Operator gt -BackgroundColor Orange
            New-HTMLTableConditionGroup -Logic AND {
                New-HTMLTableCondition -Name 'Count' -ComparisonType number -Value 1 -Operator gt
                New-HTMLTableCondition -Name 'Types' -Operator like -ComparisonType string -Value 'static'
                New-HTMLTableCondition -Name 'Types' -Operator like -ComparisonType string -Value 'dynamic'
            } -BackgroundColor Rouge -Row -Color White
        } -DataStore JavaScript
    }
    New-HTMLTab -Name 'DNS by IP' {
        New-HTMLTable -DataTable $DNSByIP -Filtering {
            New-HTMLTableCondition -Name 'Count' -ComparisonType number -Value 1 -BackgroundColor LightGreen
            New-HTMLTableCondition -Name 'Count' -ComparisonType number -Value 1 -Operator gt -BackgroundColor Orange
            New-HTMLTableConditionGroup -Logic AND {
                New-HTMLTableCondition -Name 'Count' -ComparisonType number -Value 1 -Operator gt
                New-HTMLTableCondition -Name 'Types' -Operator like -ComparisonType string -Value 'static'
                New-HTMLTableCondition -Name 'Types' -Operator like -ComparisonType string -Value 'dynamic'
            } -BackgroundColor Rouge -Row -Color White
        } -DataStore JavaScript
    }
} -ShowHTML -Online -TitleText "DNS Records" -FilePath $PSScriptRoot\DNSRecords.html

When you run the script above, you get Tabbed HTML with a lot of DNS data.

Installing ADEssentials

Those cmdlets are part of the ADEssentials module that I've been enhancing for some time now. All you need to do, to install it is:

Install-Module ADEssentials -Force -Verbose

Many commands in the ADEssentials module require RSAT (ActiveDirectory/GroupPolicy) to be present to work. Some cmdlets are ADSI based, so they don't need RSAT to work, but others from ADEssentials do.

When you install the module, it will also install PSWriteHTML and PSEventViewer. The first one is required for displaying output in HTML (tables/diagrams). The other one is a wrapper around Get-WinEvent and is used by some of the commands available within ADEssentials. Install-Module will do all that installation for you without doing anything except for the RSAT requirement, but if you're AD Admin, you should already have those up and ready to use. If you don't have admin rights on your workstation, it's still possible to use this module.

Install-Module ADEssentials -Scope CurrentUser

As those cmdlets described above are read-only, they don't require any rights in AD. I've been digesting my production environments using my standard ID. For sources, reporting issues, or feature requests, as always, visit GitHub. All my projects are hosted on it, and it's preferred method of providing support.

If you don't want to install the full module with all other 30+ useful commands here's the code for this function:

function Get-WinDNSIPAddresses {
    <#
    .SYNOPSIS
    Gets all the DNS records from all the zones within a forest sorted by IPAddress

    .DESCRIPTION
    Gets all the DNS records from all the zones within a forest sorted by IPAddress

    .PARAMETER IncludeZone
    Limit the output of DNS records to specific zones

    .PARAMETER ExcludeZone
    Limit the output of dNS records to only zones not in the exclude list

    .PARAMETER IncludeDetails
    Adds additional information such as creation time, changed time

    .PARAMETER Prettify
    Converts arrays into strings connected with comma

    .PARAMETER IncludeDNSRecords
    Include full DNS records just in case one would like to further process them

    .PARAMETER AsHashtable
    Outputs the results as a hashtable instead of an array

    .EXAMPLE
    Get-WinDNSIPAddresses | Format-Table *

    .EXAMPLE
    Get-WinDNSIPAddresses -Prettify | Format-Table *

    .EXAMPLE
    Get-WinDNSIPAddresses -Prettify -IncludeDetails -IncludeDNSRecords | Format-Table *

    .NOTES
    General notes
    #>
    [cmdletbinding()]
    param(
        [string[]] $IncludeZone,
        [string[]] $ExcludeZone,
        [switch] $IncludeDetails,
        [switch] $Prettify,
        [switch] $IncludeDNSRecords,
        [switch] $AsHashtable
    )
    $DNSRecordsCached = [ordered] @{}
    $DNSRecordsPerZone = [ordered] @{}
    $ADRecordsPerZone = [ordered] @{}

    try {
        $oRootDSE = Get-ADRootDSE -ErrorAction Stop
    } catch {
        Write-Warning -Message "Get-WinDNSIPAddresses - Could not get the root DSE. Make sure you're logged in to machine with Active Directory RSAT tools installed, and there's connecitivity to the domain. Error: $($_.Exception.Message)"
        return
    }
    $ADServer = ($oRootDSE.dnsHostName)
    $Exclusions = 'DomainDnsZones', 'ForestDnsZones', '@'
    $DNS = Get-DnsServerZone -ComputerName $ADServer
    [Array] $ZonesToProcess = foreach ($Zone in $DNS) {
        if ($Zone.ZoneType -eq 'Primary' -and $Zone.IsDsIntegrated -eq $true -and $Zone.IsReverseLookupZone -eq $false) {
            if ($Zone.ZoneName -notlike "*_*" -and $Zone.ZoneName -ne 'TrustAnchors') {
                if ($IncludeZone -and $IncludeZone -notcontains $Zone.ZoneName) {
                    continue
                }
                if ($ExcludeZone -and $ExcludeZone -contains $Zone.ZoneName) {
                    continue
                }
                $Zone
            }
        }
    }

    foreach ($Zone in $ZonesToProcess) {
        Write-Verbose -Message "Get-WinDNSIPAddresses - Processing zone for DNS records: $($Zone.ZoneName)"
        $DNSRecordsPerZone[$Zone.ZoneName] = Get-DnsServerResourceRecord -ComputerName $ADServer -ZoneName $Zone.ZoneName -RRType A
    }
    if ($IncludeDetails) {
        $Filter = { (Name -notlike "@" -and Name -notlike "_*" -and ObjectClass -eq 'dnsNode' -and Name -ne 'ForestDnsZone' -and Name -ne 'DomainDnsZone' ) }
        foreach ($Zone in $ZonesToProcess) {
            $ADRecordsPerZone[$Zone.ZoneName] = [ordered]@{}
            Write-Verbose -Message "Get-WinDNSIPAddresses - Processing zone for AD records: $($Zone.ZoneName)"
            $TempObjects = @(
                if ($Zone.ReplicationScope -eq 'Domain') {
                    try {
                        Get-ADObject -Server $ADServer -Filter $Filter -SearchBase ("DC=$($Zone.ZoneName),CN=MicrosoftDNS,DC=DomainDnsZones," + $oRootDSE.defaultNamingContext) -Properties CanonicalName, whenChanged, whenCreated, DistinguishedName, ProtectedFromAccidentalDeletion, dNSTombstoned
                    } catch {
                        Write-Warning -Message "Get-WinDNSIPAddresses - Error getting AD records for DomainDnsZones zone: $($Zone.ZoneName). Error: $($_.Exception.Message)"
                    }
                } elseif ($Zone.ReplicationScope -eq 'Forest') {
                    try {
                        Get-ADObject -Server $ADServer -Filter $Filter -SearchBase ("DC=$($Zone.ZoneName),CN=MicrosoftDNS,DC=ForestDnsZones," + $oRootDSE.defaultNamingContext) -Properties CanonicalName, whenChanged, whenCreated, DistinguishedName, ProtectedFromAccidentalDeletion, dNSTombstoned
                    } catch {
                        Write-Warning -Message "Get-WinDNSIPAddresses - Error getting AD records for ForestDnsZones zone: $($Zone.ZoneName). Error: $($_.Exception.Message)"
                    }
                } else {
                    Write-Warning -Message "Get-WinDNSIPAddresses - Unknown replication scope: $($Zone.ReplicationScope)"
                }
            )
            foreach ($DNSObject in $TempObjects) {
                $ADRecordsPerZone[$Zone.ZoneName][$DNSObject.Name] = $DNSObject
            }
        }
    }
    foreach ($Zone in $DNSRecordsPerZone.PSBase.Keys) {
        foreach ($Record in $DNSRecordsPerZone[$Zone]) {
            if ($Record.HostName -in $Exclusions) {
                continue
            }
            if (-not $DNSRecordsCached[$Record.RecordData.IPv4Address]) {
                $DNSRecordsCached[$Record.RecordData.IPv4Address] = [ordered] @{
                    IPAddress  = $Record.RecordData.IPv4Address
                    DnsNames   = [System.Collections.Generic.List[Object]]::new()
                    Timestamps = [System.Collections.Generic.List[Object]]::new()
                    Types      = [System.Collections.Generic.List[Object]]::new()
                    Count      = 0
                }
                if ($ADRecordsPerZone.Keys.Count -gt 0) {
                    $DNSRecordsCached[$Record.RecordData.IPv4Address].WhenCreated = $ADRecordsPerZone[$Zone][$Record.HostName].whenCreated
                    $DNSRecordsCached[$Record.RecordData.IPv4Address].WhenChanged = $ADRecordsPerZone[$Zone][$Record.HostName].whenChanged
                }
                if ($IncludeDNSRecords) {
                    $DNSRecordsCached[$Record.RecordData.IPv4Address].List = [System.Collections.Generic.List[Object]]::new()
                }
            }
            $DNSRecordsCached[$Record.RecordData.IPv4Address].DnsNames.Add($Record.HostName + "." + $Zone)

            if ($IncludeDNSRecords) {
                $DNSRecordsCached[$Record.RecordData.IPv4Address].List.Add($Record)
            }
            if ($null -ne $Record.TimeStamp) {
                $DNSRecordsCached[$Record.RecordData.IPv4Address].Timestamps.Add($Record.TimeStamp)
            } else {
                $DNSRecordsCached[$Record.RecordData.IPv4Address].Timestamps.Add("Not available")
            }
            if ($Null -ne $Record.Timestamp) {
                $DNSRecordsCached[$Record.RecordData.IPv4Address].Types.Add('Dynamic')
            } else {
                $DNSRecordsCached[$Record.RecordData.IPv4Address].Types.Add('Static')
            }
            $DNSRecordsCached[$Record.RecordData.IPv4Address] = [PSCustomObject] $DNSRecordsCached[$Record.RecordData.IPv4Address]

        }
    }
    foreach ($DNS in $DNSRecordsCached.PSBase.Keys) {
        $DNSRecordsCached[$DNS].Count = $DNSRecordsCached[$DNS].DnsNames.Count
        if ($Prettify) {
            $DNSRecordsCached[$DNS].DnsNames = $DNSRecordsCached[$DNS].DnsNames -join ", "
            $DNSRecordsCached[$DNS].Timestamps = $DNSRecordsCached[$DNS].Timestamps -join ", "
            $DNSRecordsCached[$DNS].Types = $DNSRecordsCached[$DNS].Types -join ", "
        }
    }
    if ($AsHashtable) {
        $DNSRecordsCached
    } else {
        $DNSRecordsCached.Values
    }
}

The post Finding duplicate DNS records by IP Address using PowerShell appeared first on Evotec.

]]>
Finding duplicate DNS entries using PowerShell https://evotec.xyz/finding-duplicate-dns-entries-using-powershell/ Sun, 24 Jul 2022 16:48:21 +0000 https://evotec.xyz/?p=17753 Today's blog post is about Active Directory-integrated DNS and how to find duplicate entries. By duplicate, I mean those where one DNS name matches multiple IP addresses. While some duplicate DNS entries are expected, in other cases, it may lead to problems. For example, having a static IP assigned to a hostname that later on is also updated with dynamic entries.

The post Finding duplicate DNS entries using PowerShell appeared first on Evotec.

]]>

Today's blog post is about Active Directory-integrated DNS and how to find duplicate entries. By duplicate, I mean those where one DNS name matches multiple IP addresses. While some duplicate DNS entries are expected, in other cases, it may lead to problems. For example, having a static IP assigned to a hostname that later on is also updated with dynamic entries.

Duplicate DNS Entries

The command I've created and added for easy use to the ADEssentials module is called Get-WinDNSRecords. Its usage is very simple. As long as you have RSAT tools for AD and DNS, it will autodetect the required settings and display results.

Get-WinDNSRecords | Format-Table

The command show multiple columns such as HostName, Zone, RecordType (probably could remove it), ListRecordIP, ListRecordTypes and Count of IP Addresses. Count makes it easy to filter for what you are looking for, and ListRecordTypes provides a quick overview of potential issues. If there is both Static and Dynamic entry for a single hostname, a server has both static entry and DHCP IP Address.

Get-WinDNSRecords provides a way to get information from Active Directory when the record was created or modified by using the IncludeDetails switch. Remember that the DNS records stored in AD for a single name have only a single entry. This means that if you have more than one record, it's impossible to tell which information is from which record. I've also added a parameter called Prettify so that it's easier to export to CSV or HTML.

Get-WinDNSRecords -Prettify -IncludeDetails | Format-Table

By default, this command gets all the zones, but one can modify its output using IncludeZone, and ExcludeZone depending on how large your AD is and what you are interested in. Finally, there is also the IncludeDNSRecords switch which provides the ability to work with complete DNS records instead of their text form.

NAME
    Get-WinDNSRecords

SYNOPSIS
    Gets all the DNS records from all the zones within a forest


SYNTAX
    Get-WinDNSRecords [[-IncludeZone] <String[]>] [[-ExcludeZone] <String[]>] [-IncludeDetails] [-Prettify] [-IncludeDNSRecords] [-AsHashtable] [<CommonParameters>]


DESCRIPTION
    Gets all the DNS records from all the zones within a forest


PARAMETERS
    -IncludeZone <String[]>
        Limit the output of DNS records to specific zones

    -ExcludeZone <String[]>
        Limit the output of dNS records to only zones not in the exclude list

    -IncludeDetails [<SwitchParameter>]
        Adds additional information such as creation time, changed time

    -Prettify [<SwitchParameter>]
        Converts arrays into strings connected with comma

    -IncludeDNSRecords [<SwitchParameter>]
        Include full DNS records just in case one would like to further process them

    -AsHashtable [<SwitchParameter>]
        Outputs the results as a hashtable instead of an array

    <CommonParameters>
        This cmdlet supports the common parameters: Verbose, Debug,
        ErrorAction, ErrorVariable, WarningAction, WarningVariable,
        OutBuffer, PipelineVariable, and OutVariable. For more information, see
        about_CommonParameters (https:/go.microsoft.com/fwlink/?LinkID=113216).

    -------------------------- EXAMPLE 1 --------------------------

    PS C:\>Get-WinDNSRecords -Prettify -IncludeDetails | Format-Table






    -------------------------- EXAMPLE 2 --------------------------

    PS C:\>$Output = Get-WinDNSRecords -Prettify -IncludeDetails -Verbose

    $Output.Count
    $Output | Sort-Object -Property Count -Descending | Select-Object -First 30 | Format-Table




REMARKS
    To see the examples, type: "get-help Get-WinDNSRecords -examples".
    For more information, type: "get-help Get-WinDNSRecords -detailed".
    For technical information, type: "get-help Get-WinDNSRecords -full".

Of course, if you're using PSWriteHTML, you can quickly pipe Get-WinDNSRecords to Out-HtmlView and have filters and all other goodies along with Excel, CSV, and PDF export ready.

Get-WinDNSRecords -IncludeDetails -Prettify | Out-HtmlView -Filtering

Installing ADEssentials

Those cmdlets are part of the ADEssentials module that I've been enhancing for some time now. All you need to do, to install it is:

Install-Module ADEssentials -Force -Verbose

Many commands in the ADEssentials module require RSAT (ActiveDirectory/GroupPolicy) to be present to work. Some cmdlets are ADSI based, so they don't need RSAT to work, but others from ADEssentials do.

When you install the module, it will also install PSWriteHTML and PSEventViewer. The first one is required for displaying output in HTML (tables/diagrams). The other one is a wrapper around Get-WinEvent and is used by some of the commands available within ADEssentials. Install-Module will do all that installation for you without doing anything except for the RSAT requirement, but if you're AD Admin, you should already have those up and ready to use. If you don't have admin rights on your workstation, it's still possible to use this module.

Install-Module ADEssentials -Scope CurrentUser

As those cmdlets described above are read-only, they don't require any rights in AD. I've been digesting my production environments using my standard ID. For sources, reporting issues, or feature requests, as always, visit GitHub. All my projects are hosted on it, and it's preferred method of providing support.

If you don't want to install the full module with all other 30+ useful commands here's the code for this function:

function Get-WinDNSRecords {
    <#
    .SYNOPSIS
    Gets all the DNS records from all the zones within a forest

    .DESCRIPTION
    Gets all the DNS records from all the zones within a forest

    .PARAMETER IncludeZone
    Limit the output of DNS records to specific zones

    .PARAMETER ExcludeZone
    Limit the output of dNS records to only zones not in the exclude list

    .PARAMETER IncludeDetails
    Adds additional information such as creation time, changed time

    .PARAMETER Prettify
    Converts arrays into strings connected with comma

    .PARAMETER IncludeDNSRecords
    Include full DNS records just in case one would like to further process them

    .PARAMETER AsHashtable
    Outputs the results as a hashtable instead of an array

    .EXAMPLE
    Get-WinDNSRecords -Prettify -IncludeDetails | Format-Table

    .EXAMPLE
    $Output = Get-WinDNSRecords -Prettify -IncludeDetails -Verbose
    $Output.Count
    $Output | Sort-Object -Property Count -Descending | Select-Object -First 30 | Format-Table

    .NOTES
    General notes
    #>
    [cmdletbinding()]
    param(
        [string[]] $IncludeZone,
        [string[]] $ExcludeZone,
        [switch] $IncludeDetails,
        [switch] $Prettify,
        [switch] $IncludeDNSRecords,
        [switch] $AsHashtable
    )
    $DNSRecordsCached = [ordered] @{}
    $DNSRecordsPerZone = [ordered] @{}
    $ADRecordsPerZone = [ordered] @{}

    try {
        $oRootDSE = Get-ADRootDSE -ErrorAction Stop
    } catch {
        Write-Warning -Message "Get-WinDNSRecords - Could not get the root DSE. Make sure you're logged in to machine with Active Directory RSAT tools installed, and there's connecitivity to the domain. Error: $($_.Exception.Message)"
        return
    }
    $ADServer = ($oRootDSE.dnsHostName)
    $Exclusions = 'DomainDnsZones', 'ForestDnsZones', '@'
    $DNS = Get-DnsServerZone -ComputerName $ADServer
    [Array] $ZonesToProcess = foreach ($Zone in $DNS) {
        if ($Zone.ZoneType -eq 'Primary' -and $Zone.IsDsIntegrated -eq $true -and $Zone.IsReverseLookupZone -eq $false) {
            if ($Zone.ZoneName -notlike "*_*" -and $Zone.ZoneName -ne 'TrustAnchors') {
                if ($IncludeZone -and $IncludeZone -notcontains $Zone.ZoneName) {
                    continue
                }
                if ($ExcludeZone -and $ExcludeZone -contains $Zone.ZoneName) {
                    continue
                }
                $Zone
            }
        }
    }

    foreach ($Zone in $ZonesToProcess) {
        Write-Verbose -Message "Get-WinDNSRecords - Processing zone for DNS records: $($Zone.ZoneName)"
        $DNSRecordsPerZone[$Zone.ZoneName] = Get-DnsServerResourceRecord -ComputerName $ADServer -ZoneName $Zone.ZoneName -RRType A
    }
    if ($IncludeDetails) {
        $Filter = { (Name -notlike "@" -and Name -notlike "_*" -and ObjectClass -eq 'dnsNode' -and Name -ne 'ForestDnsZone' -and Name -ne 'DomainDnsZone' ) }
        foreach ($Zone in $ZonesToProcess) {
            $ADRecordsPerZone[$Zone.ZoneName] = [ordered]@{}
            Write-Verbose -Message "Get-WinDNSRecords - Processing zone for AD records: $($Zone.ZoneName)"
            $TempObjects = @(
                if ($Zone.ReplicationScope -eq 'Domain') {
                    try {
                        Get-ADObject -Server $ADServer -Filter $Filter -SearchBase ("DC=$($Zone.ZoneName),CN=MicrosoftDNS,DC=DomainDnsZones," + $oRootDSE.defaultNamingContext) -Properties CanonicalName, whenChanged, whenCreated, DistinguishedName, ProtectedFromAccidentalDeletion, dNSTombstoned
                    } catch {
                        Write-Warning -Message "Get-WinDNSRecords - Error getting AD records for DomainDnsZones zone: $($Zone.ZoneName). Error: $($_.Exception.Message)"
                    }
                } elseif ($Zone.ReplicationScope -eq 'Forest') {
                    try {
                        Get-ADObject -Server $ADServer -Filter $Filter -SearchBase ("DC=$($Zone.ZoneName),CN=MicrosoftDNS,DC=ForestDnsZones," + $oRootDSE.defaultNamingContext) -Properties CanonicalName, whenChanged, whenCreated, DistinguishedName, ProtectedFromAccidentalDeletion, dNSTombstoned
                    } catch {
                        Write-Warning -Message "Get-WinDNSRecords - Error getting AD records for ForestDnsZones zone: $($Zone.ZoneName). Error: $($_.Exception.Message)"
                    }
                } else {
                    Write-Warning -Message "Get-WinDNSRecords - Unknown replication scope: $($Zone.ReplicationScope)"
                }
            )
            foreach ($DNSObject in $TempObjects) {
                $ADRecordsPerZone[$Zone.ZoneName][$DNSObject.Name] = $DNSObject
            }
        }
    }
    foreach ($Zone in $DNSRecordsPerZone.PSBase.Keys) {
        foreach ($Record in $DNSRecordsPerZone[$Zone]) {
            if ($Record.HostName -in $Exclusions) {
                continue
            }
            if (-not $DNSRecordsCached["$($Record.HostName).$($Zone)"]) {
                $DNSRecordsCached["$($Record.HostName).$($Zone)"] = [ordered] @{
                    'HostName' = $Record.HostName
                    'Zone'     = $Zone
                    #'RecordType' = $Record.RecordType
                    RecordIP   = [System.Collections.Generic.List[Object]]::new()
                    Types      = [System.Collections.Generic.List[Object]]::new()
                    Timestamps = [System.Collections.Generic.List[Object]]::new()
                    Count      = 0
                }
                if ($ADRecordsPerZone.Keys.Count -gt 0) {
                    $DNSRecordsCached["$($Record.HostName).$($Zone)"].WhenCreated = $ADRecordsPerZone[$Zone][$Record.HostName].whenCreated
                    $DNSRecordsCached["$($Record.HostName).$($Zone)"].WhenChanged = $ADRecordsPerZone[$Zone][$Record.HostName].whenChanged
                }
                if ($IncludeDNSRecords) {
                    $DNSRecordsCached["$($Record.HostName).$($Zone)"].List = [System.Collections.Generic.List[Object]]::new()
                }
            }
            if ($IncludeDNSRecords) {
                $DNSRecordsCached["$($Record.HostName).$($Zone)"].List.Add($Record)
            }
            if ($null -ne $Record.TimeStamp) {
                $DNSRecordsCached["$($Record.HostName).$($Zone)"].Timestamps.Add($Record.TimeStamp)
            } else {
                $DNSRecordsCached["$($Record.HostName).$($Zone)"].Timestamps.Add("Not available")
            }
            $DNSRecordsCached["$($Record.HostName).$($Zone)"].RecordIP.Add($Record.RecordData.IPv4Address)
            if ($Null -ne $Record.Timestamp) {
                $DNSRecordsCached["$($Record.HostName).$($Zone)"].Types.Add('Dynamic')
            } else {
                $DNSRecordsCached["$($Record.HostName).$($Zone)"].Types.Add('Static')
            }
            $DNSRecordsCached["$($Record.HostName).$($Zone)"] = [PSCustomObject] $DNSRecordsCached["$($Record.HostName).$($Zone)"]

        }
    }
    foreach ($DNS in $DNSRecordsCached.PSBase.Keys) {
        $DNSRecordsCached[$DNS].Count = $DNSRecordsCached[$DNS].RecordIP.Count
        if ($Prettify) {
            $DNSRecordsCached[$DNS].Types = $DNSRecordsCached[$DNS].Types -join ", "
            $DNSRecordsCached[$DNS].RecordIP = $DNSRecordsCached[$DNS].RecordIP -join ", "
            $DNSRecordsCached[$DNS].Timestamps = $DNSRecordsCached[$DNS].Timestamps -join ", "
        }
    }
    if ($AsHashtable) {
        $DNSRecordsCached
    } else {
        $DNSRecordsCached.Values
    }
}

The post Finding duplicate DNS entries using PowerShell appeared first on Evotec.

]]>
OfficeIMO – Free cross-platform Microsoft Word .NET library https://evotec.xyz/officeimo-free-cross-platform-microsoft-word-net-library/ Sun, 12 Jun 2022 16:14:01 +0000 https://evotec.xyz/?p=17679 I've created a cross-platform (Windows, Linux, macOS) Word library based on Open XML SDK that heavily simplifies creating and modifying Word documents. Open XML SDK, while excellent, requires you to do a lot of work to make even simple documents. For example, if you want to use Table styles, you need first to define those styles, put them in a specific place, and assign them to a table. The same goes for lists, images, hyperlinks, bookmarks, and many other Microsoft Word types. Creating sections, managing headers, and footers - all that is possible using Open XML SDK, but it's far from easy. At least for a noob like me. You have to know the order to put them into the document; you must know the places and track IDs to all the elements. And trust me - it's not fun.

The post OfficeIMO – Free cross-platform Microsoft Word .NET library appeared first on Evotec.

]]>

About 12 years ago, I worked for a company that had their entire client database written in Microsoft Excel (each Client, in a separate file, later on, combined into one large Excel for reporting) and Microsoft Access. Whenever anyone required confirmation on paper about their holdings, the person would generate a Word document manually confirming their portfolio of products to the Client. As you can imagine, this was pretty unmanageable. There was a massive problem with efficiency. A lot of work went into fixing errors in Excel files. Generating Word files by hand was not great either. This was when I was tasked with creating a program that would take over. The only problem was – I had never written the program before. Sure, I wrote some scripts, but nothing major. After struggling to learn C# for weeks, I found this DocX library that would promise to create Word documents without the need for Interop. It was straightforward to use and had all the features I needed. After some time and a bit of helping with the library, I was given it to maintain while the original owner went away.

A few years later, after struggling to support the library (people are kind of demanding), we decided to give it away to a company that would promise to keep it free while establishing some business model. And they did. I could use the DocX library for years without much of a problem. The only downside was it only supports .NET Framework, and the world has moved on. While I could accept the .NET Framework limitation because of my need to interact with DocX using PowerShell primarily, they also changed licensing model recently, making it very difficult to write anything with it and not think about it every single time, whether it breaches a license or not. The NET limitation and licensing model made the DocX library useless for me, so I decided to do something about it. I started exploring, and Office XML SDK seemed a great way to create Word documents. But while it's a great library with many options, it doesn't do a lot of heavy lifting for you. It gives you a hammer and some nails and lets you go on your own. That's why I've decided to simplify things and make it easy to create even complicated documents without knowing all the ins and outs of how Word documents are built.

TL;DR

I've created a cross-platform (Windows, Linux, macOS) Word library based on Open XML SDK that heavily simplifies creating and modifying Word documents. Open XML SDK, while excellent, requires you to do a lot of work to make even simple documents. For example, if you want to use Table styles, you need first to define those styles, put them in a specific place, and assign them to a table. The same goes for lists, images, hyperlinks, bookmarks, and many other Microsoft Word types. Creating sections, managing headers, and footers – all that is possible using Open XML SDK, but it's far from easy. At least for a noob like me. You have to know the order to put them into the document; you must know the places and track IDs to all the elements. And trust me – it's not fun.

OfficeIMO.Word library supports following .NET versions

  • .NET 4.7.2
  • .NET 4.8
  • .NET 5.0
  • .NET 6.0
  • .NET Standard 2.0
  • .NET Standard 2.1

OfficeIMO is free for commercial usage with no limits of any kind. Since it's one of the reasons why I decided to stop using the DocX project, it would make no sense to introduce any limits of any kind. OfficeIMO.Word is a wrapper around Open XML SDK and provides a much easier way to build Word documents using NET. While there is a project called OfficeIMO.Excel I have just played around for a few hours with it and mostly created it to see how it could work, and maybe something I will expand on in the future. However, there are other excellent alternatives for Excel, so I'm not thinking about it for now.

Creating basic Word Document using C#

To show you what's in the OfficeIMO.Word library, let's start with something simple. Creating a bare and empty Word document with three properties such as Title, Creator, or Keywords is as easy as five lines of code.

public static void Example_BasicEmptyWord(string folderPath, bool openWord) {
    Console.WriteLine("[*] Creating standard document (empty)");
    string filePath = System.IO.Path.Combine(folderPath, "EmptyDocument.docx");
    using (WordDocument document = WordDocument.Create(filePath)) {
        document.BuiltinDocumentProperties.Title = "This is my title";
        document.BuiltinDocumentProperties.Creator = "Przemysław Kłys";
        document.BuiltinDocumentProperties.Keywords = "word, docx, test";
        document.Save(openWord);
    }
}

OfficeIMO Empty Word Document

Adding paragraphs with text and some styling to Microsoft Word using C#

Of course, who would create an empty document? Adding some paragraphs with texts and changing its styling, changing alignment, or adding color can be done by changing properties.

public static void Example_BasicWord(string folderPath, bool openWord) {
    Console.WriteLine("[*] Creating standard document with paragraph");
    string filePath = System.IO.Path.Combine(folderPath, "BasicDocumentWithParagraphs.docx");
    using (WordDocument document = WordDocument.Create(filePath)) {
        var paragraph = document.AddParagraph("Adding paragraph with some text");
        paragraph.ParagraphAlignment = JustificationValues.Center;
        Console.WriteLine(SixLabors.ImageSharp.Color.Blue.ToHexColor());
        Console.WriteLine(SixLabors.ImageSharp.Color.Crimson.ToHexColor());
        Console.WriteLine(SixLabors.ImageSharp.Color.Aquamarine.ToHexColor());

        paragraph.Color = SixLabors.ImageSharp.Color.Red;

        paragraph = document.AddParagraph("Adding another paragraph with some more text");
        paragraph.Bold = true;
        paragraph = paragraph.AddText(" , but now we also decided to add more text to this paragraph using different style");
        paragraph.Underline = UnderlineValues.DashLong;
        paragraph = paragraph.AddText(" , and we still continue adding more text to existing paragraph.");
        paragraph.Color = SixLabors.ImageSharp.Color.CornflowerBlue;

        document.Save(openWord);
    }
}

Adding sections, changing page size and page orientation using C#

OfficeIMO isn't all about basic stuff tho. It can create and modify sections, add and remove paragraphs, add and change comments, and adjust page size and orientation. With minimal effort, as shown in the example below, you can define a pretty complicated Word Document without knowing the document's structure that would be otherwise required when playing with Open XML SDK.

public static void Example_BasicWord2(string folderPath, bool openWord) {
    Console.WriteLine("[*] Creating standard document with paragraph (2)");
    string filePath = System.IO.Path.Combine(folderPath, "BasicDocumentWithParagraphs2.docx");
    using (WordDocument document = WordDocument.Create(filePath)) {

        document.Settings.ZoomPercentage = 50;
        var paragraph = document.AddParagraph("Basic paragraph");

        var section1 = document.AddSection();
        section1.AddParagraph("Test Middle Section - 1");

        var section2 = document.AddSection();
        section2.AddParagraph("Test Last Section - 1");
        section1.AddParagraph("Test Middle Section - 2").AddComment("Adam Kłys", "AK", "Another test");
        var test = document.AddParagraph("Test 1 - to delete");
        test.Remove();
        section1.PageSettings.PageSize = WordPageSize.A5;
        section2.PageOrientation = PageOrientationValues.Landscape;

        document.Sections[2].AddParagraph("Test 0 - Section Last");
        document.Sections[1].AddParagraph("Test 1").AddComment("Przemysław Kłys", "PK", " This is just a test");

        Console.WriteLine("----");
        Console.WriteLine("Sections: " + document.Sections.Count);
        Console.WriteLine("----");
        Console.WriteLine(document.Sections[0].Paragraphs.Count);
        Console.WriteLine(document.Sections[1].Paragraphs.Count);
        Console.WriteLine(document.Sections[2].Paragraphs.Count);

        Console.WriteLine(document.Comments.Count);

        document.Comments[0].Text = "Lets change it";
        document.Save(false);
    }

    using (WordDocument document = WordDocument.Load(filePath)) {
        Console.WriteLine("----");
        Console.WriteLine(document.Sections.Count);
        Console.WriteLine("----");
        Console.WriteLine(document.Sections[0].Paragraphs.Count);
        Console.WriteLine(document.Sections[0].Paragraphs.Count);
        Console.WriteLine(document.Sections[0].Paragraphs.Count);

        Console.WriteLine(document.Sections[0].HyperLinks.Count);
        Console.WriteLine(document.HyperLinks.Count);
        Console.WriteLine(document.Fields.Count);
        document.Save(true);
    }
}

Adding tables, lists, table of content and more using C#

While the above examples are pretty simple OfficeIMO feature set for Word has a lot more to offer. Adding sections, headers, and footers, changing page size, and page orientation. Adding watermark, adding a cover page, adding a table of content, adding lists, adding tables, and so much more. You can see in the below example a lot of those features shown, and how most of them are basically one line of code.

public static void Example_AdvancedWord(string folderPath, bool openWord) {
    Console.WriteLine("[*] Creating advanced document");
    string filePath = System.IO.Path.Combine(folderPath, "AdvancedDocument.docx");
    using (WordDocument document = WordDocument.Create(filePath)) {
        // lets add some properties to the document
        document.BuiltinDocumentProperties.Title = "Cover Page Templates";
        document.BuiltinDocumentProperties.Subject = "How to use Cover Pages with TOC";
        document.ApplicationProperties.Company = "Evotec Services";

        // we force document to update fields on open, this will be used by TOC
        document.Settings.UpdateFieldsOnOpen = true;

        // lets add one of multiple added Cover Pages
        document.AddCoverPage(CoverPageTemplate.IonDark);

        // lets add Table of Content (1 of 2)
        document.AddTableOfContent(TableOfContentStyle.Template1);

        // lets add page break
        document.AddPageBreak();

        // lets create a list that will be binded to TOC
        var wordListToc = document.AddTableOfContentList(WordListStyle.Headings111);

        wordListToc.AddItem("How to add a table to document?");

        document.AddParagraph("In the first paragraph I would like to show you how to add a table to the document using one of the 105 built-in styles:");

        // adding a table and modifying content
        var table = document.AddTable(5, 4, WordTableStyle.GridTable5DarkAccent5);
        table.Rows[3].Cells[2].Paragraphs[0].Text = "Adding text to cell";
        table.Rows[3].Cells[2].Paragraphs[0].Color = Color.Blue; ;
        table.Rows[3].Cells[3].Paragraphs[0].Text = "Different cell";

        document.AddParagraph("As you can see adding a table with some style, and adding content to it ").SetBold().SetUnderline(UnderlineValues.Dotted).AddText("is not really complicated").SetColor(Color.OrangeRed);

        wordListToc.AddItem("How to add a list to document?");

        var paragraph = document.AddParagraph("Adding lists is similar to ading a table. Just define a list and add list items to it. ").SetText("Remember that you can add anything between list items! ");
        paragraph.SetColor(Color.Blue).SetText("For example TOC List is just another list, but defining a specific style.");

        var list = document.AddList(WordListStyle.Bulleted);
        list.AddItem("First element of list", 0);
        list.AddItem("Second element of list", 1);

        var paragraphWithHyperlink = document.AddHyperLink("Go to Evotec Blogs", new Uri("https://evotec.xyz"), true, "URL with tooltip");
        // you can also change the hyperlink text, uri later on using properties
        paragraphWithHyperlink.Hyperlink.Uri = new Uri("https://evotec.xyz/hub");
        paragraphWithHyperlink.ParagraphAlignment = JustificationValues.Center;

        list.AddItem("3rd element of list, but added after hyperlink", 0);
        list.AddItem("4th element with hyperlink ").AddHyperLink("included.", new Uri("https://evotec.xyz/hub"), addStyle: true);

        document.AddParagraph();

        var listNumbered = document.AddList(WordListStyle.Heading1ai);
        listNumbered.AddItem("Different list number 1");
        listNumbered.AddItem("Different list number 2", 1);
        listNumbered.AddItem("Different list number 3", 1);
        listNumbered.AddItem("Different list number 4", 1);

        var section = document.AddSection();
        section.PageOrientation = PageOrientationValues.Landscape;
        section.PageSettings.PageSize = WordPageSize.A4;

        wordListToc.AddItem("Adding headers / footers");

        // lets add headers and footers
        document.AddHeadersAndFooters();

        // adding text to default header
        document.Header.Default.AddParagraph("Text added to header - Default");

        var section1 = document.AddSection();
        section1.PageOrientation = PageOrientationValues.Portrait;
        section1.PageSettings.PageSize = WordPageSize.A5;

        wordListToc.AddItem("Adding custom properties and page numbers to document");

        document.CustomDocumentProperties.Add("TestProperty", new WordCustomProperty { Value = DateTime.Today });
        document.CustomDocumentProperties.Add("MyName", new WordCustomProperty("Some text"));
        document.CustomDocumentProperties.Add("IsTodayGreatDay", new WordCustomProperty(true));

        // add page numbers
        document.Footer.Default.AddPageNumber(WordPageNumberStyle.PlainNumber);

        // add watermark
        document.Sections[0].AddWatermark(WordWatermarkStyle.Text, "Draft");

        document.Save(openWord);
    }
}

Of course, the OfficeIMO Word library isn't complete. There are bugs, missing features, and things I've not thought about. I am not a developer, and I mainly created this for my own needs but decided to share it with everyone. Having said that:

  • If you see bad practice, please open an issue/submit PR.
  • If you know how to do something in OpenXML that could help this project – please open an issue/submit a PR
  • If you see something that could work better – please open an issue/submit a PR
  • If you see something that I made a fool of myself – please open an issue/submit a PR
  • If you see something that works not the way I think it works – please open an issue/submit a PR

I hope you get the drift? If it's terrible – open an issue/fix it! I don't know what I'm doing! OfficeIMO is hosted on GitHub and is open source.

OfficeIMO - How do I get it?

How do you install it? The easiest and most optimal way is to use Nuget.org. This will get you up and running in no time.

  • Code is published as a nuget on Nuget.org
  • Issues should be reported on GitHub
  • Code is published on GitHub

On GitHub, you can also find multiple other examples and a multitude of tests that go thru different functionality.

The post OfficeIMO – Free cross-platform Microsoft Word .NET library appeared first on Evotec.

]]>
Reading IIS logs with PowerShell https://evotec.xyz/reading-iis-logs-with-powershell/ Sat, 04 Jun 2022 19:47:08 +0000 https://evotec.xyz/?p=17644 Today I was reading Twitter, as I am pretty addicted to technology news when Adam Bacon mentioned that he's surprised that no one has rebuilt IIS Parser as pure PowerShell. While this is not entirely true, and some modules can do some parsing, I decided to try my luck. While doing it from scratch in PowerShell is possible, I opted to use an external C# library that does all the heavy lifting and is optimized for speed.

The post Reading IIS logs with PowerShell appeared first on Evotec.

]]>

Today I was reading Twitter, as I am pretty addicted to technology news when Adam Bacon mentioned that he's surprised that no one has rebuilt IIS Parser as pure PowerShell. While this is not entirely true, and some modules can do some parsing, I decided to try my luck. While doing it from scratch in PowerShell is possible, I opted to use an external C# library that does all the heavy lifting and is optimized for speed.

Using IISParser to read IIS Logs

So after about 1 hour of playing with the library and creating a new PowerShell module called IISParser, here we are.  We now have an easy-to-use PowerShell function to read IIS Logs.

Get-IISParsedLog -FilePath "C:\Support\GitHub\IISParser\Ignore\u_ex220507.log" | Select-Object -First 5 | Format-Table
Get-IISParsedLog -FilePath "C:\Support\GitHub\IISParser\Ignore\u_ex220507.log" | Select-Object -First 5 -Last 5 | Format-Table

IIS Logs Parser in PowerShell

Of course, when using Format-Table, we are not shown all the columns, but if we force the output using Format-List, we get all the IIS properties.

Get-IISParsedLog -FilePath "C:\Support\GitHub\IISParser\Ignore\u_ex220507.log" | Select-Object -First 5 -Last 5 | Format-List

IISParsers All Properties

Installing and updating IISParser

How do you install it? The easiest and most optimal way is to use PowerShellGallery. This will get you up and running in no time. Whenever there is an update, just run Update-Module, and you're done.

Install-Module IISParser
# Update-Module IISParser

However, if you're into code – want to see how everything is done, you can use GitHub sources. Please keep in mind that the PowerShellGallery version is optimized and better for production use. If you see any issues, bugs, or features that are missing, please make sure to submit them on GitHub.

The post Reading IIS logs with PowerShell appeared first on Evotec.

]]>
PowerShell – Comparing advanced objects https://evotec.xyz/powershell-comparing-advanced-objects/ Mon, 28 Feb 2022 15:22:25 +0000 https://evotec.xyz/?p=17266 Two years ago, I wrote a blog post on how you can compare two or more objects visually in PowerShell that works on Windows, Linux, or macOS. I've been using that for a while, but it had a specific flaw. Comparing more advanced objects that you often see (for example, returned by Graph API, two config files) wasn't working correctly, often throwing errors. The reason for this was that having nested hashtables arrays require more advanced logic. Today I've updated my module to use the ConvertTo-FlatObject function, which allows the Compare-MultipleObjects function to compare suitably more advanced objects hopefully. Of course, it should not throw errors anymore.

The post PowerShell – Comparing advanced objects appeared first on Evotec.

]]>

Two years ago, I wrote a blog post on how you can compare two or more objects visually in PowerShell that works on Windows, Linux, or macOS. I've been using that for a while, but it had a specific flaw. Comparing more advanced objects that you often see (for example, returned by Graph API, two config files) wasn't working correctly, constantly throwing errors. The reason for this was that having nested hashtables arrays require more advanced logic. Today I've updated my module to use the ConvertTo-FlatObject function, which allows the Compare-MultipleObjects function to compare suitably more advanced objects hopefully. Of course, it should not throw errors anymore. I wrote a blog post about ConvertTo-FlatObject functionality yesterday. Feel free to use it separately from PSWriteHTML.

Defining the problem

Let's take two or more objects with the same structure but different data. It's pretty easy to compare it if the object is one-dimensional, but things are not that obvious once you try to reach more complicated objects. Below, I have two objects that look the same at first, but I've changed a few values.

$Object1 = [PSCustomObject] @{
    "Name"    = "Przemyslaw Klys"
    "Age"     = "30"
    "Address" = @{
        "Street"  = "Kwiatowa"
        "City"    = "Warszawa"

        "Country" = [ordered] @{
            "Name" = "Poland"
        }
        List      = @(
            [PSCustomObject] @{
                "Name" = "Adam Klys"
                "Age"  = "32"
            }
            [PSCustomObject] @{
                "Name" = "Justyna Klys"
                "Age"  = "33"
            }
            [PSCustomObject] @{
                "Name" = "Justyna Klys"
                "Age"  = 30
            }
            [PSCustomObject] @{
                "Name" = "Justyna Klys"
                "Age"  = $null
            }
        )
    }
    ListTest  = @(
        [PSCustomObject] @{
            "Name" = "Sława Klys"
            "Age"  = "33"
        }
    )
}
$Object2 = [PSCustomObject] @{
    "Name"    = "Przemyslaw Klys"
    "Age"     = "30"
    "Address" = @{
        "Street"  = "Kwiatowa"
        "City"    = "Warszawa"

        "Country" = [ordered] @{
            "Name" = "Gruzja"
        }
        List      = @(
            [PSCustomObject] @{
                "Name" = "Adam Klys"
                "Age"  = "32"
            }
            [PSCustomObject] @{
                "Name" = "Pankracy Klys"
                "Age"  = "33"
            }
            [PSCustomObject] @{
                "Name" = "Justyna Klys"
                "Age"  = 30
            }
            [PSCustomObject] @{
                "Name" = "Justyna Klys"
                "Age"  = $null
            }
        )
    }
    ListTest  = @(
        [PSCustomObject] @{
            "Name" = "Sława Klys"
            "Age"  = "33"
        }
    )
}

Having two objects, we now use the Compare-MultipleObjects function with the FlattenObject switch, which first flattens an advanced object to a single dimension and then does property to property comparison.

Compare-MultipleObjects -Objects $Object1, $Object2 -FlattenObject | Format-Table

As you can see FlattenObject switch converted all nested objects into a single dimension. It converted nested hashtables arrays into properties on the top level. I've also added a new parameter called ObjectsName, which allows overriding the name of columns. It no longer needs to work with column names such as Source, 1, 2, 3, and instead, use your names. Notice I'm also using a Summary switch which allows for quick comparison where values are the same and where those are different.

Compare-MultipleObjects -Objects $Object1, $Object2 -FlattenObject -Summary -ObjectsName 'Object1', 'Object2' | Format-Table *

Comparing Two Objects Visually using PSWriteHTML

After updating Compare-MultipleObjects without much change on the PSWriteHTML module, we now get a working comparison (no more errors – hopefully). To make it more obvious, I've added some colors for the Status column where it currently says True, False, or $null and gives it colors (Green, Red, Yellow).

$Object1,$Object2 | Out-HtmlView -Compare -HighlightDifferences -Filtering -WordBreak break-all

Of course, this is not what you wanted. It no longer throws errors but skips comparing nested objects. You wanted a complete comparison, not just properties that are easy, right? This is where the FlattenObject switch comes into play. Both Out-HtmlView and New-HTMLTable now have it as an option.

I've added more nested objects and properties in the following test to make it more complicated and show the differences.

$Object1 = [PSCustomObject] @{
    "Name"    = "Przemyslaw Klys"
    "Age"     = "30"
    "Address" = @{
        "Street"  = "Kwiatowa"
        "City"    = "Warszawa"

        "Country" = [ordered] @{
            "Name" = "Poland"
        }
        List      = @(
            [PSCustomObject] @{
                "Name" = "Adam Klys"
                "Age"  = "32"
            }
            [PSCustomObject] @{
                "Name" = "Justyna Klys"
                "Age"  = "33"
            }
        )
    }
    ListTest  = @(
        [PSCustomObject] @{
            "Name" = "Justyna Klys"
            "Age"  = "33"
        }
    )
}
$Object2 = [PSCustomObject] @{
    "Name"    = "Przemyslaw Klys"
    "Age"     = "30"
    "Address" = @{
        "Street"  = "Kwiatowa"
        "City"    = "Warszawa"
        
        "Country" = [ordered] @{
            "Name" = "Gruzja"
        }
        List      = @(
            [PSCustomObject] @{
                "Name" = "Adam Klys"
                "Age"  = "32"
            }
            [PSCustomObject] @{
                "Name" = "Pankracy Klys"
                "Age"  = "33"
            }
            [PSCustomObject] @{
                "Name" = "Justyna Klys"
                "Age"  = 30
            }
            [PSCustomObject] @{
                "Name" = "Justyna Klys"
                "Age"  = $null
            }
        )
    }
    ListTest  = @(
        [PSCustomObject] @{
            "Name" = "Sława Klys"
            "Age"  = "33"
        }
    )
    MoreProperties = $true
}

As you can notice, I'm using the new switch FlattenObject, which will convert nested objects to a single dimension.

$Object1,$Object2 | Out-HtmlView -Compare -HighlightDifferences -Filtering -WordBreak break-all -FlattenObject

The flattening of an object causes the properties to have a bit different naming. Every nested object property is joined with its parent name property using a dot. This influences the length of properties and changes a bit original object but allows for comparison to be appropriately displayed in a single dimension (table). Of course, if it's comparing nested arrays, it's the order that matters. This should make it pretty easy to compare two or more objects, even when those are complicated. For example, comparing two Intune policies (before and after)

$Object19 = Get-Content -Raw -LiteralPath "$PSScriptRoot\MDM_iOS_General_Policies.json" | ConvertFrom-Json
$Object20 = Get-Content -Raw -LiteralPath "$PSScriptRoot\TEMP_WW_MDM_iOS_General_Policies.json" | ConvertFrom-Json
$Object19,$Object20 | Out-HtmlView -Compare -HighlightDifferences -Filtering -WordBreak break-all -ExcludeProperty "*odata*","#microsoft*" -FlattenObject -CompareNames "MDM_iOS_General_Policies", "TEMP_MDM_iOS_General_Policies" -SkipProperties

What matters is – they should be similar objects (to some degree).

Using PSWriteHTML as PowerShell Module

For easy use and installation, PSWriteHTML is available from PowerShellGallery. Installing is as easy as typing a simple command in the PowerShell window.

Install-Module PSWriteHTML -AllowClobber -Force

PSWriteHTML covers New-HTMLTable and Out-HtmlView functionality. If you are interested in ConvertTo-FlatObject or Compare-MultipleObjects, you need to install the PSSharedGoods PowerShell module.

Install-Module PSSharedGoods -AllowClobber -Force

Sources as always on GitHub.

The post PowerShell – Comparing advanced objects appeared first on Evotec.

]]>
PowerShell – Converting advanced object to flat object https://evotec.xyz/powershell-converting-advanced-object-to-flat-object/ Sun, 27 Feb 2022 13:57:20 +0000 https://evotec.xyz/?p=17264 PowerShell language allows you to work and build complicated objects. There are multiple ways to save them, such as XML or JSON, but sometimes using them is impossible or inadequate. Sometimes you want to use HTML or CSV or any other single dimension output.

The post PowerShell – Converting advanced object to flat object appeared first on Evotec.

]]>

PowerShell language allows you to work and build complicated objects. There are multiple ways to save them, such as XML or JSON, but sometimes using them is impossible or inadequate. Sometimes you want to use HTML or CSV or any other single dimension output.

HTML Export for nested objects

Let's say you have an advanced PowerShell object with nested properties. It's a great feature of PowerShell, but displaying it to the user saving it as CSV or HTML will give you results that will not be very useful.

$Object3 = [PSCustomObject] @{
    "Name"    = "Przemyslaw Klys"
    "Age"     = "30"
    "Address" = @{
        "Street"  = "Kwiatowa"
        "City"    = "Warszawa"

        "Country" = [ordered] @{
            "Name" = "Poland"
        }
        List      = @(
            [PSCustomObject] @{
                "Name" = "Adam Klys"
                "Age"  = "32"
            }
            [PSCustomObject] @{
                "Name" = "Justyna Klys"
                "Age"  = "33"
            }
        )
    }
    ListTest  = @(
        [PSCustomObject] @{
            "Name" = "Justyna Klys"
            "Age"  = "33"
        }
    )
}

$Object4 = [PSCustomObject] @{
    "Name"    = "Przemyslaw Klys"
    "Age"     = "30"
    "Address" = @{
        "Street"  = "Kwiatowa"
        "City"    = "Warszawa"
        "Country" = [ordered] @{
            "Name" = "Gruzja"
        }
        List      = @(
            [PSCustomObject] @{
                "Name" = "Adam Klys"
                "Age"  = "32"
            }
            [PSCustomObject] @{
                "Name" = "Pankracy Klys"
                "Age"  = "33"
            }
            [PSCustomObject] @{
                "Name" = "Justyna Klys"
                "Age"  = 30
            }
            [PSCustomObject] @{
                "Name" = "Justyna Klys"
                "Age"  = $null
            }
        )
    }
    ListTest  = @(
        [PSCustomObject] @{
            "Name" = "Sława Klys"
            "Age"  = "33"
        }
    )
    MoreProperties = $true
}
$Object3, $Object4 | Out-HtmlView -Filtering

While Name and Age properties output was proper, the rest – not so much.

Export-CSV for nested objects

The same problem will be visible in the Export-CSV command.

$Object3, $Object4 | Export-Csv -Path "$PSScriptRoot\test.csv" -NoTypeInformation

Converting Advanced PowerShell object to flat objects

While you can always use JSON and XML as mentioned to save files – wouldn't it be nice to have another option? This is where ConvertTo-FlatObject comes in! It converts advanced objects into flat ones.

$Object3, $Object4 | ConvertTo-FlatObject | Export-Csv -Path "$PSScriptRoot\test.csv" -NoTypeInformation -Encoding UTF8

The same goes for Out-HTMLView and New-HTMLTable from PSWriteHTML module. As part of the module, I've added a parameter called FlattenObject which internally uses ConvertTo-FlatObject to make it easy for end-users.

$Object3, $Object4 | Out-HtmlView -Filtering -FlattenObject

Office 365 Example of using ConvertTo-FlatObject inside Out-HTMLView

If you're wondering what it could be useful for – let me give you an example. Get-AzureADAuditSignInLogs is a cmdlet from Office 365 to get information about logging in users to Office 365.

Connect-AzureAD
$Logins = Get-AzureADAuditSignInLogs
$Logins[0]

This means that once you push it to CSV or HTML using Out-HTMLView, all those nested properties would be hidden away or mainly unreadable, unsortable.

As you see above – it's far from perfect. I would usually take my PowerShell skills and convert those advanced objects into smaller, one-dimensional objects to display exactly what I wanted. If I had 50 columns, I would rebuild the thing entirely, and it would take some time to do it. Or I would use new functionality and just like that, with a single line of code get it all in one go.

$Logins | Out-HtmlView -ScrollX -Filtering -FlattenObject
$Logins | Out-HtmlView -ScrollX -Filtering -FlattenObject -ExcludeProperty "*IsReadOnly","*Count"

Notice that I'm using ExcludeProperty to filter out some columns that are often part of the object that I don't want to see. Isn't it much nicer? Easily readable, zero effort. Report ready in 15 minutes.

Installing PSSharedGoods

ConvertTo-FlatObject is part of the PSSharedGoods PowerShell module. This is my “glue” module with loads of functions. It contains over 200 functions that allow me to reuse those when needed. How do you install it? The easiest and most optimal way is to use PowerShellGallery. This will get you up and running in no time. Whenever there is an update, just run Update-Module, and you're done.

Install-Module PSSharedGoods
# Update-Module PSSharedGoods

If you prefer New-HTMLTable or Out-HTMLView commands, you may want to install PSWriteHTML instead.

Install-Module PSWriteHTML
# Update-Module PSWriteHTML

However, if you're into code – want to see how everything is done, you can use GitHub sources. Please keep in mind that the PowerShellGallery version is optimized and better for production use. If you see any issues, bugs, or features that are missing, please make sure to submit them on GitHub.

Direct source code for ConvertTo-FlatObject

If you don't like using modules, you can use code directly.

Function ConvertTo-FlatObject {
    <#
    .SYNOPSIS
    Flattends a nested object into a single level object.

    .DESCRIPTION
    Flattends a nested object into a single level object.

    .PARAMETER Objects
    The object (or objects) to be flatten.

    .PARAMETER Separator
    The separator used between the recursive property names

    .PARAMETER Base
    The first index name of an embedded array:
    - 1, arrays will be 1 based: <Parent>.1, <Parent>.2, <Parent>.3, …
    - 0, arrays will be 0 based: <Parent>.0, <Parent>.1, <Parent>.2, …
    - "", the first item in an array will be unnamed and than followed with 1: <Parent>, <Parent>.1, <Parent>.2, …

    .PARAMETER Depth
    The maximal depth of flattening a recursive property. Any negative value will result in an unlimited depth and could cause a infinitive loop.

    .PARAMETER Uncut
    The maximal depth of flattening a recursive property. Any negative value will result in an unlimited depth and could cause a infinitive loop.

    .EXAMPLE
    $Object3 = [PSCustomObject] @{
        "Name"    = "Przemyslaw Klys"
        "Age"     = "30"
        "Address" = @{
            "Street"  = "Kwiatowa"
            "City"    = "Warszawa"

            "Country" = [ordered] @{
                "Name" = "Poland"
            }
            List      = @(
                [PSCustomObject] @{
                    "Name" = "Adam Klys"
                    "Age"  = "32"
                }
                [PSCustomObject] @{
                    "Name" = "Justyna Klys"
                    "Age"  = "33"
                }
                [PSCustomObject] @{
                    "Name" = "Justyna Klys"
                    "Age"  = 30
                }
                [PSCustomObject] @{
                    "Name" = "Justyna Klys"
                    "Age"  = $null
                }
            )
        }
        ListTest  = @(
            [PSCustomObject] @{
                "Name" = "Sława Klys"
                "Age"  = "33"
            }
        )
    }

    $Object3 | ConvertTo-FlatObject

    .NOTES
    Based on https://powersnippets.com/convertto-flatobject/
    #>
    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeLine)][Object[]]$Objects,
        [String]$Separator = ".",
        [ValidateSet("", 0, 1)]$Base = 1,
        [int]$Depth = 5,
        [Parameter(DontShow)][String[]]$Path,
        [Parameter(DontShow)][System.Collections.IDictionary] $OutputObject
    )
    Begin {
        $InputObjects = [System.Collections.Generic.List[Object]]::new()
    }
    Process {
        foreach ($O in $Objects) {
            $InputObjects.Add($O)
        }
    }
    End {
        If ($PSBoundParameters.ContainsKey("OutputObject")) {
            $Object = $InputObjects[0]
            $Iterate = [ordered] @{}
            if ($null -eq $Object) {
                #Write-Verbose -Message "ConvertTo-FlatObject - Object is null"
            } elseif ($Object.GetType().Name -in 'String', 'DateTime', 'TimeSpan', 'Version', 'Enum') {
                $Object = $Object.ToString()
            } elseif ($Depth) {
                $Depth--
                If ($Object -is [System.Collections.IDictionary]) {
                    $Iterate = $Object
                } elseif ($Object -is [Array] -or $Object -is [System.Collections.IEnumerable]) {
                    $i = $Base
                    foreach ($Item in $Object.GetEnumerator()) {
                        $Iterate["$i"] = $Item
                        $i += 1
                    }
                } else {
                    foreach ($Prop in $Object.PSObject.Properties) {
                        if ($Prop.IsGettable) {
                            $Iterate["$($Prop.Name)"] = $Object.$($Prop.Name)
                        }
                    }
                }
            }
            If ($Iterate.Keys.Count) {
                foreach ($Key in $Iterate.Keys) {
                    ConvertTo-FlatObject -Objects @(, $Iterate["$Key"]) -Separator $Separator -Base $Base -Depth $Depth -Path ($Path + $Key) -OutputObject $OutputObject
                }
            } else {
                $Property = $Path -Join $Separator
                $OutputObject[$Property] = $Object
            }
        } elseif ($InputObjects.Count -gt 0) {
            foreach ($ItemObject in $InputObjects) {
                $OutputObject = [ordered]@{}
                ConvertTo-FlatObject -Objects @(, $ItemObject) -Separator $Separator -Base $Base -Depth $Depth -Path $Path -OutputObject $OutputObject
                [PSCustomObject] $OutputObject
            }
        }
    }
}

The post PowerShell – Converting advanced object to flat object appeared first on Evotec.

]]>