Behind the PowerShell Pipeline logo

Behind the PowerShell Pipeline

Subscribe
Archives
July 25, 2025

Solving the June 2025 Scripting Challenge

At the end of last month, I left you with a new scripting challenge. These challenges are intended to give you a reason to use PowerShell to solve a practical problem. Now, you may not need to solve this particular problem, but the process you experience while working through the challenge will help you learn how to use PowerShell to solve other problems in the future.

The challenge I left for you focused on the Certificate provider in PowerShell. I gave you a few tasks to complete using PowerShell. Here's how I tackled the challenge. As always, my code is not the only way or even the best way to solve the problem. Regardless, I hope you find it informative.

Finding Expired Certificates

The first task was to find expired certificates on the local computer. The Certificate provider exposes the certificate store through a PSDrive called Cert. We add the colon when we reference the drive.

PS C:\> dir cert:\

Location   : CurrentUser
StoreNames : {[SmartCardRoot, True], [AuthRoot, True], [UserDS, True],
             [Disallowed, True]...}

Location   : LocalMachine
StoreNames : {[My, True], [WindowsServerUpdateServices, True],
             [TrustedPublisher, True], [eSIM Certification Authorities, True]...}

Since this might be a new area, you might want to explore and discover. I always get a sample object to see what properties are available.

PS C:\> dir Cert:\CurrentUser\my | Select-Object -First 1 -Property *

PSPath                   : Microsoft.PowerShell.Security\Certificate::CurrentUse
                           r\my\BFCDBFE0CF7331D41BDA7D58377128A4E833BF13
PSParentPath             : Microsoft.PowerShell.Security\Certificate::CurrentUse
                           r\my
PSChildName              : BFCDBFE0CF7331D41BDA7D58377128A4E833BF13
PSDrive                  : Cert
PSProvider               : Microsoft.PowerShell.Security\Certificate
PSIsContainer            : False
EnhancedKeyUsageList     : {}
DnsNameList              : {9972287b-4dca-4c85-ba67-1531014e7e8d}
SendAsTrustedIssuer      : False
EnrollmentPolicyEndPoint : Microsoft.CertificateServices.Commands.EnrollmentEndP
                           ointProperty
EnrollmentServerEndPoint : Microsoft.CertificateServices.Commands.EnrollmentEndP
                           ointProperty
PolicyId                 :
Archived                 : False
Extensions               : {}
FriendlyName             : Microsoft Your Phone
HasPrivateKey            : True
PrivateKey               :
IssuerName               : System.Security.Cryptography.X509Certificates.X500Dis
                           tinguishedName
NotAfter                 : 3/21/2026 8:38:03 AM
NotBefore                : 3/20/2025 8:38:03 PM
PublicKey                : System.Security.Cryptography.X509Certificates.PublicK
                           ey
RawData                  : {48, 130, 1, 145...}
RawDataMemory            : System.ReadOnlyMemory<Byte>[405]
SerialNumber             : 00E84342D84715ED6C
SignatureAlgorithm       : System.Security.Cryptography.Oid
SubjectName              : System.Security.Cryptography.X509Certificates.X500Dis
                           tinguishedName
Thumbprint               : BFCDBFE0CF7331D41BDA7D58377128A4E833BF13
Version                  : 3
Handle                   : 1491086180048
Issuer                   : CN=9972287b-4dca-4c85-ba67-1531014e7e8d
Subject                  : CN=9972287b-4dca-4c85-ba67-1531014e7e8d
SerialNumberBytes        : System.ReadOnlyMemory<Byte>[9]

I could use Get-Member but Select-Object shows my the property name and value. Based on this output, I can see the NotAfter and NotBefore properties. These look appropriate for my task.

dir cert: -Recurse |
where { $_.NotAfter -and ($_.NotAfter -lt (Get-Date)) } |
Sort-Object -Property NotAfter |
Select-Object -Property NotAfter, Subject,
@{Name="Path";Expression={Join-Path -Path "Cert:" -childPath (Convert-Path $_.PSPath)}},
@{Name="Computername";Expression = {$Env:COMPUTERNAME}} |
Format-List

The certificate object doesn't have an explicit path property. I'm constructing one using Join-Path and Convert-Path. The latter cmdlet converts the PSPath property into something more "filesystem-like."

NotAfter     : 5/8/2021 4:40:55 PM
Subject      : CN=Token Signing Public Key
Path         : Cert:\LocalMachine\Windows Live ID Token Issuer\2C85006A1A028BCC349DF23C474724C055FDE8B6
Computername : CADENZA

NotAfter     : 5/9/2021 7:28:13 PM
Subject      : CN=Microsoft Root Certificate Authority, DC=microsoft, DC=com
Path         : Cert:\LocalMachine\Root\CDD4EEAE6000AC7F40C3802C171E30148030C072
Computername : CADENZA

I am not limiting my search to user or machine certificates. The code is searching the entire certificate store.

Finding Expiring Certificates

The next task was to find certificates that will expire in the next 90 or 180 days. On my laptop, I had to increase to 360 days to get results.

PS C:\> dir Cert: -Recurse -ExpiringInDays 360 | sort NotAfter |
Select-Object NotAfter,
@{Name="Path";Expression={Join-Path -Path "Cert:" -childPath (Convert-Path $_.PSPath)}},
@{Name="Computername";Expression = {$Env:COMPUTERNAME}} |
Format-List

NotAfter     : 12/30/1999 6:59:59 PM
Path         : Cert:\LocalMachine\Root\245C97DF7514E7CF2DF8BE72AE957B9E04741E85
Computername : CADENZA

NotAfter     : 12/30/1999 6:59:59 PM
Path         : Cert:\CurrentUser\Root\245C97DF7514E7CF2DF8BE72AE957B9E04741E85
Computername : CADENZA
...

You can see that I have been using Get-Childitem (aliased as dir) to explore the certificate store. The -Recurse parameter allows me to search through all the sub-stores just as I with files and folders. The -ExpiringInDays parameter is a dynamic parameter that the Certificate provider adds to the Get-ChildItem cmdlet. You may not see it in the help unless you are in the a Cert: location. Fortunately, the help for Get-ChildItem explicitly documents this parameter.

PS C:\> help Get-ChildItem -Parameter ExpiringInDays

-ExpiringInDays <System.Int32>
    > [!NOTE] > This parameter is only available in the > Certificate
    (../Microsoft.PowerShell.Security/About/about_Certificate_Provider.md)provider.

    Specifies that the cmdlet should only return certificates that are expiring in or before the specified number of days. A value of zero (`0`)
    gets certificates that have expired.

    This parameter was reintroduced in PowerShell 7.1

    Required?                    false
    Position?                    named
    Default value                None
    Accept pipeline input?       False
    Aliases                      none
    Accept wildcard characters?  false

This isn't always the case, especially in Windows PowerShell. You should change location to the PSDrive you are working in and look at help for the commands you want to use. There may be a dynamic parameter that you can use to simplify your code.

There is one slight wrinkle to this output in that it includes expired certificates as well and I want to see certificate that are expiring in the near future. This should be easy enough to filter out.

PS C:\> dir Cert: -Recurse -ExpiringInDays 360 |
where NotAfter -ge (Get-Date) | sort NotAfter |
Select-Object NotAfter, issuer,
@{Name="Path";Expression={Join-Path -Path "Cert:" -childPath (Convert-Path $_.PSPath)}},
@{Name="Computername";Expression = {$Env:COMPUTERNAME}} |
Format-List

NotAfter     : 3/20/2026 1:10:33 PM
Issuer       : CN=localhost
Path         : Cert:\CurrentUser\My\170AD6583F8E1FB4D6F37A5D7D29BBA84A35AEFA
Computername : CADENZA

NotAfter     : 3/21/2026 8:38:03 AM
Issuer       : CN=9972287b-4dca-4c85-ba67-1531014e7e8d
Path         : Cert:\CurrentUser\My\BFCDBFE0CF7331D41BDA7D58377128A4E833BF13
Computername : CADENZA

NotAfter     : 7/8/2026 5:09:09 PM
Issuer       : CN=Microsoft Root Certificate Authority 2011, O=Microsoft Corporation, L=Redmond, S=Washington, C=US
Path         : Cert:\CurrentUser\CA\F252E794FE438E35ACE6E53762C0A234A2C52135
Computername : CADENZA

The second part of this challenge was to take this solution and wrap it in a function so that you could query a remote computer. Since you can only access the CERT: PSDrive on the local computer, you need to use PowerShell Remoting to run the command on the remote computer.

#requires -version 7.5
function Get-ExpiredCertificate {
    [cmdletbinding(DefaultParameterSetName = 'Expired')]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]$Computername = $env:COMPUTERNAME,
        [Parameter(ParameterSetName = 'Expired')]
        [switch]$Expired,
        [Parameter(
            HelpMessage = 'Find certificates that will expire in this number of days.',
            ParameterSetName = 'Days'
        )]
        [ValidateNotNullOrEmpty()]
        [int32]$DaysToExpire,
        [ValidateNotNullOrEmpty()]
        [PSCredential]$Credential
    )

    begin {
        Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN  ] Starting $($MyInvocation.MyCommand)"
        #define script blocks to run remotely
        $findExpired = {
            Get-ChildItem cert: -Recurse |
            Where-Object { $_.NotAfter -and ($_.NotAfter -lt (Get-Date)) }

        } #findExpired

        $findExpiring = {
            Get-ChildItem Cert: -Recurse -ExpiringInDays $using:DaysToExpire |
            Where-Object {$_.NotAfter -ge (Get-Date)}
        }

        $splat = @{
            HideComputerName = $true
        }
        if ($Credential) {
            $splat.Add("Credential",$Credential)
        }
    } #begin
    process {
        $splat["Computername"] = $Computername
        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Using parameter set $($PSCmdlet.ParameterSetName)"
        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Querying $ComputerName"
        if ($PSCmdlet.ParameterSetName -eq 'Expired') {
            $splat["ScriptBlock"] = $findExpired
        }
        else {
            Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Finding certificates to expire in $DaysToExpire days"
            $splat["ScriptBlock"] = $findExpiring
        }
        $certs = Invoke-Command @splat
        if ($certs) {
            Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Found $($certs.count) matching certificate(s)"
            $out = foreach ($cert in $certs) {
                [PSCustomObject]@{
                    PSTypeName   = 'PSCertExpireInfo'
                    NotAfter     = $cert.NotAfter
                    Subject      = $cert.Subject
                    Path         = Join-Path -Path 'Cert:' -ChildPath (Convert-Path $cert.PSPath)
                    Computername = $Env:COMPUTERNAME
                }
            }
            $out | Sort-Object -Property NotAfter
        }
        else {
            Write-Warning 'No matching certificates found.'
        }
    } #process
    end {
        Write-Verbose "[$((Get-Date).TimeOfDay) END    ] Ending $($MyInvocation.MyCommand)"
    } #end'
}

My function uses parameter sets so that I can either find all expired certificates or those about to expire. This code is run in a script block remotely using Invoke-Command. Assuming there are matching certificates, the function processes the certificate data and writes a custom object to the pipeline.

The default behavior is to find all expired certificates.

PS C:\> Get-ExpiredCertificate -Computername Cadenza | Measure-Object

Count             : 25
Average           :
Sum               :
Maximum           :
Minimum           :
StandardDeviation :
Property          :

Or I can find expiring certificates.

Get expiring certifcates remotely
figure 1

Certification Authorities

The last task was to find the certification authorities on the local computer and the number of associated certificates. When I use Get-ChildItem on the Cert: PSDrive, I am getting back different types of objects. I am getting not only the certificate, but also the certificate store. This is similar to getting directories and files. I am only interested in certificates. Here's on way I could limit my query.

$c = dir cert:\ -Recurse |
where { $_ -is [System.Security.Cryptography.X509Certificates.X509Certificate2] }

I want to use the Issuer property to group the certificates by the issuing authority. I can use Group-Object to do this.

PS C:\> $c[1].issuer
CN=UTN-USERFirst-Object, OU=http://www.usertrust.com, O=The USERTRUST Network, L=Salt Lake City, S=UT, C=US

I could use a simple expression like:

PS C:\> $c | Group Issuer -NoElement | Sort count -Descending | Select -first 5

Count Name
----- ----
    6 OU=Class 3 Public Primar...
    5 CN=Certum Trusted Networ...
    5 CN=DigiCert Trusted Root...
    4 CN=VeriSign Class 3 Publ...
    4 CN=UTN-USERFirst-Object,...

But I want to create a custom object. For the issuer, I want to use the organization element of the fully qualified name if it exists, such as O=The USERTRUST Network. If this element doesn't exist, then I'll use the common name (CN) element, such as CN=UTN-USERFirst-Object.

I could try splitting the Issuer property

$n = $c[1].issuer.split(',').trim() | where { $_ -match 'O=' }
$o = $n.split('=')[1]
````

This technique would also work for parsing the CN.

```powershell
PS C:\> $c[11].issuer.split(',').trim() | where { $_ -match 'CN=' }

CN=DigiCert Global Root CA

However, this will be a problem if there is a comma in the organization or CN name.

I can use a regular expression to extract the organization or common name from the issuer.

PS C:\> [regex]$rxOrg = '(?<=O=).*?((?=\s\w+=)|$)'
PS C:\> $rxOrg.Match($c[1].Issuer).Value
The USERTRUST Network,

My pattern leaves a trailing comma, but I can easily remove it

PS C:\> $rxOrg.Match($c[1].Issuer).Value -replace ",",""
The USERTRUST Network

I tried using a regex lookahead but I could never get a consistent result. Fine-tuning the regex result is just as easy.

I ended up with a helper function to extract the organization or common name from the issuer.

function parseIssuer {
    param([string]$issuer)

    #the look ahead is optional
    [regex]$rxOrg = '(?<=O=).*?((?=\s\w+=)|$)'

    [regex]$rxCN = '(?<=CN=).*?(?=,)'
    if ($rxOrg.IsMatch($issuer)) {
        #write-host "Org"
        $rxOrg.Match($issuer).Value -replace '(,$)|"', ''
    }
    elseif ($rxCN.IsMatch($issuer)) {
        #write-host "CN"
        $rxCN.Match($issuer).Value #-replace '(,$)|"',''
    }
    else {
        #nothing matches so show the value
        $issuer
    }
}

I always test on a subset of data.

PS C:\> $c[0..9] | foreach { parseIssuer $_.issuer} | Group-Object -NoElement

Count Name
----- ----
    1 Comodo CA Limited
    2 DigiCert Inc
    1 GlobalSign
    1 GlobalSign nv-sa
    1 Internet Security Resear…
    1 Microsoft Corporation
    1 Starfield Technologies, …
    1 The USERTRUST Network
    1 Unizeto Technologies S.A.

I then built a function to return the certification authorities and the number of certificates issued by each authority using PowerShell remoting and my helper function.

function Get-CAIssuer {
    [cmdletbinding()]
    [OutputType('CAIssuerInfo')]
    [alias('gcai')]
    param(
        [Parameter(
            Position = 0,
            ValueFromPipeline,
            HelpMessage = 'Specify the name of a remote computer'
        )]
        [ValidateNotNullOrEmpty()]
        [string]$ComputerName = $env:COMPUTERNAME,
        [PSCredential]$Credential
    )

    begin {
        Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN  ] Starting $($MyInvocation.MyCommand)"
        Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN  ] Running under PowerShell version $($PSVersionTable.PSVersion)"
        $get = {
            $certs = Get-ChildItem cert:\ -Recurse |
            Where-Object { $_ -is [System.Security.Cryptography.X509Certificates.X509Certificate2] }
            $certs.Issuer
        }
        function parseIssuer {
            param([string]$issuer)

            #the look ahead is optional
            [regex]$rxOrg = '(?<=O=).*?((?=\s\w+=)|$)'
            [regex]$rxCN = '(?<=CN=).*?(?=,)'
            if ($rxOrg.IsMatch($issuer)) {
                $rxOrg.Match($issuer).Value -replace '(,$)|"', ''
            }
            elseif ($rxCN.IsMatch($issuer)) {
                $rxCN.Match($issuer).Value #-replace '(,$)|"',''
            }
            else {
                #nothing matches so show the value
                $issuer
            }
        }
        $PSBoundParameters["ScriptBlock"] = $get
    } #begin

    process {
        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $ComputerName "
        $data = Invoke-Command @PSBoundParameters
        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Found $($data.count) certificate issuers"
        $data.Foreach({parseIssuer $_}) | Group-Object -NoElement |
        ForEach-Object {
            #create a typed custom object
            [PSCustomObject]@{
                PSTypeName= "CAIssuerInfo"
                ComputerName = $Computername.ToUpper()
                Count = $_.Count
                Issuer = $_.Name
            }
         } | Sort-Object -Property Count,Issuer
    } #process

    end {
        Write-Verbose "[$((Get-Date).TimeOfDay) END    ] Ending $($MyInvocation.MyCommand)"
    } #end

} #close Get-CAIssuer

Now I can see how many certificates are issued by each authority on a given computer.

PS C:\> gcai | sort count -Descending | Select -first 10

ComputerName Count Issuer
------------ ----- ------
CADENZA         31 Microsoft Corporation
CADENZA         25 DigiCert Inc
CADENZA         14 VeriSign, Inc.
CADENZA         12 Starfield Technologies, Inc.
CADENZA          9 Unizeto Technologies S.A.
CADENZA          8 The USERTRUST Network
CADENZA          8 GlobalSign
CADENZA          4 The Go Daddy Group, Inc.
CADENZA          4 SSL Corporation
CADENZA          4 Microsoft Root Authority

My example is using the function alias I defined. If I were to continue to develop this the next step would be to create a custom formatting file.

Summary

Although, the real value in all of this is the code I wrote and how I got there. I can likely re-use techniques and concepts in future projects. This is why I am always telling IT Pros that the best way to learn PowerShell is to use it every day. Sometimes you need a reason, and these challenges are a great way to get you learning and thinking about PowerShell.

(c) 2022-2025 JDH Information Technology Solutions, Inc. - all rights reserved
Don't miss what's next. Subscribe to Behind the PowerShell Pipeline:
Start the conversation:
GitHub Bluesky LinkedIn About Jeff
Powered by Buttondown, the easiest way to start and grow your newsletter.