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.

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.