From 670fcf12ba2a5e8cb8d073930f6e4ebe5e569f95 Mon Sep 17 00:00:00 2001 From: Chris Hunt <114173+cdhunt@users.noreply.github.com> Date: Tue, 2 Jan 2024 17:00:44 -0500 Subject: [PATCH] Adds SSLCertificate commands (#9) * Adds SSLCertificate commands * Get-SSLCertificate * Show-SSLCertificateUI * Test-SSLCertificate --- Changelog.md | 4 + README.md | 31 ++++++ build.ps1 | 27 ++--- docs/Get-SSLCertificate.md | 69 +++++++++++++ docs/Invoke-HttpUnit.md | 38 +++---- docs/README.md | 9 +- docs/Show-SSLCertificateUI.md | 28 +++++ docs/Test-SSLCertificate.md | 54 ++++++++++ src/httpunitPS.psm1 | 26 ++--- src/internal/Export-HelpToMd.ps1 | 148 --------------------------- src/public/Get-SSLCertificate.ps1 | 141 +++++++++++++++++++++++++ src/public/Invoke-HttpUnit.ps1 | 2 +- src/public/Show-SSLCertificateUI.ps1 | 42 ++++++++ src/public/Test-SSLCertificate.ps1 | 91 ++++++++++++++++ test/Get-SSLCertificate.Tests.ps1 | 51 +++++++++ test/Test-SSLCertificate.Tests.ps1 | 54 ++++++++++ version.json | 2 +- 17 files changed, 609 insertions(+), 208 deletions(-) create mode 100644 docs/Get-SSLCertificate.md create mode 100644 docs/Show-SSLCertificateUI.md create mode 100644 docs/Test-SSLCertificate.md delete mode 100644 src/internal/Export-HelpToMd.ps1 create mode 100644 src/public/Get-SSLCertificate.ps1 create mode 100644 src/public/Show-SSLCertificateUI.ps1 create mode 100644 src/public/Test-SSLCertificate.ps1 create mode 100644 test/Get-SSLCertificate.Tests.ps1 create mode 100644 test/Test-SSLCertificate.Tests.ps1 diff --git a/Changelog.md b/Changelog.md index 7e84d9c..80a6f92 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,9 @@ # Changelog +## v0.5 + +- Adds SSLCertificate commands + ## v0.4 - Switch to [Import-ConfigData](https://github.com/cdhunt/Import-ConfigData) for Toml parsing diff --git a/README.md b/README.md index d3dc3a0..0c4e034 100644 --- a/README.md +++ b/README.md @@ -56,3 +56,34 @@ Each `[[plan]]` lists: [plan.headers] Server = "gws" ``` + +### Test-SSLCertificate + +The SSLCertificate commands may be moved to a separate module in the future. + +- [Get-SSLCertificate](docs/Get-SSLCertificate.md) _Get the SSL Certificate for given host._ +- [Show-SSLCertificateUI](docs/Show-SSLCertificateUI.md) _Displays a dialog box with detailed information about the specified x509 certificate._ +- [Test-SSLCertificate](docs/Test-SSLCertificate.md) _Test the validitiy of a given certificate._ + +```powershell +PS > Get-SSLCertificate expired.badssl.com | Test-SSLCertificate -ErrorVariable validation +False +``` + +Validation failures produces an error message. + +```text +Test-SSLCertificate: Certificate failed chain validation: +A required certificate is not within its validity period when verifying against the current system clock or the timestamp in the signed file. +``` + +Inspect the [certificate chain](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.x509chain) inside the ErrorRecord. + +```powershell +PS > $validation.TargetObject.ChainElements.Certificate +Thumbprint Subject EnhancedKeyUsageList +---------- ------- -------------------- +404BBD2F1F4CC2FDEEF13AABDD523EF61F1C71F3 CN=*.badssl.com, OU… {Server Authentication, Client Authentication} +339CDD57CFD5B141169B615FF31428782D1DA639 CN=COMODO RSA Domai… {Server Authentication, Client Authentication} +AFE5D244A8D1194230FF479FE2F897BBCD7A8CB4 CN=COMODO RSA Certi… +``` diff --git a/build.ps1 b/build.ps1 index 7201245..c4accdc 100644 --- a/build.ps1 +++ b/build.ps1 @@ -176,24 +176,25 @@ function Publish { function Docs { param () + Import-Module C:\source\Build-Docs\publish\Build-Docs\ Import-Module $publish -Force - $commands = Get-Command -Module $module -CommandType Function - $HelpToMd = [System.IO.Path]::Combine($src, 'internal', 'Export-HelpToMd.ps1') - . $HelpToMd + $help = Get-HelpModuleData $module - @("# $module", [System.Environment]::NewLine) | Set-Content -Path "$docs/README.md" - $($manifest.Description) | Add-Content -Path "$docs/README.md" - @('## Cmdlets', [System.Environment]::NewLine) | Add-Content -Path "$docs/README.md" + # docs/README.md + $help | New-HelpDoc | + Add-ModuleProperty Name -H1 | + Add-ModuleProperty Description | + Add-HelpDocText "Commands" -H2 | + Add-ModuleCommands -AsLinks | + Out-HelpDoc -Path 'docs/README.md' - foreach ($command in $Commands | Sort-Object -Property Verb) { + # Individual Commands + foreach ($command in $help.Commands) { $name = $command.Name - $docPath = Join-Path -Path $docs -ChildPath "$name.md" - $help = Get-Help -Name $name - - Export-HelpToMd $help | Set-Content -Path $docPath - - "- [$name]($name.md) $($help.Synopsis)" | Add-Content -Path "$docs/README.md" + $doc = New-HelpDoc -HelpModuleData $help + $doc.Text = $command.ToMD() + $doc | Out-HelpDoc -Path "docs/$name.md" } ChangeLog | Set-Content -Path "$parent/Changelog.md" diff --git a/docs/Get-SSLCertificate.md b/docs/Get-SSLCertificate.md new file mode 100644 index 0000000..a5e67d3 --- /dev/null +++ b/docs/Get-SSLCertificate.md @@ -0,0 +1,69 @@ +# Get-SSLCertificate + +Open an SSL connection to the given host and read the presented server certificate. + +## Parameters + +### Parameter Set 1 + +- `[String]` **ComputerName** _A hostname or Url of the server to retreive the certificate._ Mandatory +- `[Int32]` **Port** _The port to connect to the remote server._ +- `[String]` **OutSslStreamVariable** _Stores SslStream connetion details from the command in the specified variable._ + +## Examples + +### Example 1 + +Return the certificate for google.com. + +```powershell +Get-SSLCertificate google.com +Thumbprint Subject EnhancedKeyUsageList +---------- ------- -------------------- +9B97772CC2C860B0D0663AD3ED34272FF927EDEE CN=*.google.com Server Authentication +``` +### Example 2 + +Verify a server certificate. You can use Test-SSLCertificate to validate the entire certificate chain. + +```powershell +$cert = Get-SSLCertificate expired.badssl.com +$cert.Verify() +False +``` +### Example 3 + +Write SslStream connection details to Verbose stream. + +```powershell +$cert = Get-SSLCertificate google.com -verbose +VERBOSE: Converting Uri to host string +VERBOSE: ComputerName = google.com +VERBOSE: Cipher: Aes256 strength 256 +VERBOSE: Hash: Sha384 strength 0 +VERBOSE: Key exchange: None strength 0 +VERBOSE: Protocol: Tls13 +``` +### Example 4 + +Stores SslStream connection details in the `$sslStreamValue` variable. + +```powershell +Get-SSLCertificate -ComputerName 'google.com' -OutSslStreamVariable sslStreamValue +Thumbprint Subject EnhancedKeyUsageList +---------- ------- -------------------- +5D3AD94714B07830A1BFB445F6F581AD0AC77689 CN=*.google.com Server Authentication +$sslStreamValue +CipherAlgorithm : Aes256 +CipherStrength : 256 +HashAlgorithm : Sha384 +HashStrength : 0 +KeyExchangeAlgorithm : None +KeyExchangeStrength : 0 +SslProtocol : Tls13 +``` + +## Links + +- [Invoke-HttpUnit](Invoke-HttpUnit.md) +- [Test-SSLCertificate](Test-SSLCertificate.md) diff --git a/docs/Invoke-HttpUnit.md b/docs/Invoke-HttpUnit.md index 67a1fc2..f646401 100644 --- a/docs/Invoke-HttpUnit.md +++ b/docs/Invoke-HttpUnit.md @@ -1,40 +1,32 @@ # Invoke-HttpUnit - This is not a 100% accurate port of httpunit. The goal of this module is to utilize Net.Http.HttpClient to more closely simulate a .Net client application. It also provides easy access to the Windows Certificate store for client certificate authentication. -## Parameters +## Parameters ### Parameter Set 1 - -- `[String]` **Url** _The URL to retrieve._ Mandatory -- `[String]` **Code** _For http/https, the expected status code, default 200._ -- `[String]` **String** _For http/https, a string we expect to find in the result._ -- `[Hashtable]` **Headers** _For http/https, a hashtable to validate the response headers._ -- `[TimeSpan]` **Timeout** _A timeout for the test. Default is 3 seconds._ -- `[X509Certificate]` **Certificate** _For http/https, specifies the client certificate that is used for a secure web request. Enter a variable that contains a certificate._ -- `[String]` **Method** _For http/https, the HTTP method to send._ -- `[switch]` **Quiet** _Do not output ErrorRecords for failed tests._ - +- `[String]` **Url** _The URL to retrieve._ Mandatory +- `[String]` **Code** _For http/https, the expected status code, default 200._ +- `[String]` **String** _For http/https, a string we expect to find in the result._ +- `[Hashtable]` **Headers** _For http/https, a hashtable to validate the response headers._ +- `[TimeSpan]` **Timeout** _A timeout for the test. Default is 3 seconds._ +- `[X509Certificate]` **Certificate** _For http/https, specifies the client certificate that is used for a secure web request. Enter a variable that contains a certificate._ +- `[String]` **Method** _For http/https, the HTTP method to send._ +- `[Switch]` **Quiet** _Do not output ErrorRecords for failed tests._ ### Parameter Set 2 - -- `[String]` **Path** _Specifies a path to a configuration file with a list of tests. Supported types are .toml, .yml, and .psd1._ Mandatory, ValueFromPipeline -- `[String[]]` **Tag** _If specified, only runs plans that are tagged with one of the tags specified._ -- `[switch]` **Quiet** _Do not output ErrorRecords for failed tests._ - +- `[String]` **Path** _Specifies a path to a configuration file with a list of tests. Supported types are .toml, .yml, and .psd1._ Mandatory, ValueFromPipeline +- `[String[]]` **Tag** _If specified, only runs plans that are tagged with one of the tags specified._ +- `[Switch]` **Quiet** _Do not output ErrorRecords for failed tests._ ## Examples - ### Example 1 - Run an ad-hoc test against one Url. - ```powershell Invoke-HttpUnit -Url https://www.google.com -Code 200 Label : https://www.google.com/ @@ -47,14 +39,10 @@ GotHeaders : False InvalidCert : False TimeTotal : 00:00:00.4695217 ``` - - ### Example 2 - Run all of the tests in a given config file. - ```powershell Invoke-HttpUnit -Path .\example.toml Label : google @@ -86,9 +74,7 @@ InvalidCert : False TimeTotal : 00:00:00.1021738 ``` - ## Links - - [https://github.com/StackExchange/httpunit](https://github.com/StackExchange/httpunit) - [https://github.com/cdhunt/Import-ConfigData](https://github.com/cdhunt/Import-ConfigData) diff --git a/docs/README.md b/docs/README.md index 9fe034a..5343e83 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,11 @@ # httpunitPS - A PowerShell port of httpunit. -## Cmdlets +## Commands + +- [Get-SSLCertificate](Get-SSLCertificate.md) _Get the SSL Certificate for given host._ +- [Invoke-HttpUnit](Invoke-HttpUnit.md) _A PowerShell port of httpunit._ +- [Show-SSLCertificateUI](Show-SSLCertificateUI.md) _Displays a dialog box with detailed information about the specified x509 certificate._ +- [Test-SSLCertificate](Test-SSLCertificate.md) _Test the validitiy of a given certificate._ -- [Invoke-HttpUnit](Invoke-HttpUnit.md) A PowerShell port of httpunit. diff --git a/docs/Show-SSLCertificateUI.md b/docs/Show-SSLCertificateUI.md new file mode 100644 index 0000000..fd2a560 --- /dev/null +++ b/docs/Show-SSLCertificateUI.md @@ -0,0 +1,28 @@ +# Show-SSLCertificateUI + +Displays a dialog box with detailed information about the specified x509 certificate. The dialog box includes buttons for installing or copying the certificate. + +## Parameters + +### Parameter Set 1 + +- `[X509Certificate2]` **Certificate** _An X509Certificate2 certificate object._ Mandatory, ValueFromPipeline + +### Parameter Set 2 + +- `[String]` **ComputerName** _A hostname or Url of the server to retreive the certificate to test._ Mandatory +- `[Int32]` **Port** _The port to connect to the remote server._ + +## Examples + +### Example 1 + +Launches a certificate dialogue box with details about the google.com certificate. + +```powershell +Get-SSLCertificate google.com | Show-SSLCertificateUI +``` + +## Links + +- [Get-SSLCertificate](Get-SSLCertificate.md) diff --git a/docs/Test-SSLCertificate.md b/docs/Test-SSLCertificate.md new file mode 100644 index 0000000..79970be --- /dev/null +++ b/docs/Test-SSLCertificate.md @@ -0,0 +1,54 @@ +# Test-SSLCertificate + +Verifies the entire certificates chain from a certificate object or hostname. + +## Parameters + +### Parameter Set 1 + +- `[X509Certificate2]` **Certificate** _An X509Certificate2 certificate object._ Mandatory, ValueFromPipeline +- `[Switch]` **RevocationMode** _The Revocation Mode to use in validation. +NoCheck: No revocation check is performed on the certificate. +Offline: A revocation check is made using a cached certificate revocation list (CRL). +Online: A revocation check is made using an online certificate revocation list (CRL)._ + +### Parameter Set 2 + +- `[Switch]` **RevocationMode** _The Revocation Mode to use in validation. +NoCheck: No revocation check is performed on the certificate. +Offline: A revocation check is made using a cached certificate revocation list (CRL). +Online: A revocation check is made using an online certificate revocation list (CRL)._ +- `[String]` **ComputerName** _A hostname or Url of the server to retreive the certificate to test._ Mandatory +- `[Int32]` **Port** _The port to connect to the remote server._ + +## Examples + +### Example 1 + +Test the validity of the google SSL Certificate. + +```powershell +Get-SSLCertificate google.com | Test-SSLCertificates +True +``` +### Example 2 + +Tests an invalid certificates and inspect the error in variable `$validation` for the certificate details. + +```powershell +Test-SSLCertificate expired.badssl.com -ErrorVariable validation +Test-SSLCertificate: Certificate failed chain validation: +A required certificate is not within its validity period when verifying against the current system clock or the timestamp in the signed file. +False +$validation.TargetObject.ChainElements.Certificate +Thumbprint Subject EnhancedKeyUsageList +---------- ------- -------------------- +404BBD2F1F4CC2FDEEF13AABDD523EF61F1C71F3 CN=*.badssl.com, OU… {Server Authentication, Client Authentication} +339CDD57CFD5B141169B615FF31428782D1DA639 CN=COMODO RSA Domai… {Server Authentication, Client Authentication} +AFE5D244A8D1194230FF479FE2F897BBCD7A8CB4 CN=COMODO RSA Certi… +``` + +## Links + +- [Get-SSLCertificate](Get-SSLCertificate.md) +- [https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.x509chain?view=net-8.0#remarks](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.x509chain?view=net-8.0#remarks) diff --git a/src/httpunitPS.psm1 b/src/httpunitPS.psm1 index fb48ded..720a871 100644 --- a/src/httpunitPS.psm1 +++ b/src/httpunitPS.psm1 @@ -128,8 +128,7 @@ class TestCase { if ($response.StatusCode -ne $this.ExpectCode) { $exception = [Exception]::new(("Unexpected status code: {0}" -f $response.StatusCode)) $result.Result = [System.Management.Automation.ErrorRecord]::new($exception, "1", "InvalidResult", $response) - } - else { + } else { $result.GotCode = $true } @@ -143,8 +142,7 @@ class TestCase { if (!$responseContent.Contains($this.ExpectText)) { $exception = [Exception]::new(("Response does not contain text {0}" -f $response.ExpectText)) $result.Result = [System.Management.Automation.ErrorRecord]::new($exception, "2", "InvalidResult", $response) - } - else { + } else { $result.GotText = $true } } @@ -163,12 +161,10 @@ class TestCase { if ($foundValue -like $expectedValue) { continue - } - else { + } else { $headerMatchErrors += "$keyExpected=$foundValue, Expecting $expectedValue" } - } - else { + } else { $headerMatchErrors += "Header '$keyExpected' does not exist" } } @@ -177,25 +173,21 @@ class TestCase { $errorMessage = $headerMatchErrors -join "; " $exception = [Exception]::new(("Response headers do not match: {0}" -f $errorMessage)) $result.Result = [System.Management.Automation.ErrorRecord]::new($exception, "3", "InvalidResult", $response.Headers) - } - else { + } else { $result.GotHeaders = $true } } - } - catch [System.Threading.Tasks.TaskCanceledException] { + } catch [System.Threading.Tasks.TaskCanceledException] { $exception = [Exception]::new(("Request timed out after {0:N2}s" -f $this.Plan.Timeout.TotalSeconds)) $result.Result = [System.Management.Automation.ErrorRecord]::new($exception, "4", "OperationTimeout", $client) - } - catch { + } catch { if ($_.Exception.GetBaseException().Message -like 'The remote certificate is invalid*') { $result.InvalidCert = $true } $result.Result = [System.Management.Automation.ErrorRecord]::new($_.Exception.GetBaseException(), "5", "ConnectionError", $client) - } - finally { + } finally { $result.TimeTotal = (Get-Date) - $time } @@ -223,3 +215,5 @@ class TestResult { } } +# https://learn.microsoft.com/en-us/dotnet/api/system.net.security.remotecertificatevalidationcallback?view=net-8.0 +$ServerCertificateCustomValidation_AlwaysTrust = { param($senderObject, $cert, $chain, $errors) return $true } diff --git a/src/internal/Export-HelpToMd.ps1 b/src/internal/Export-HelpToMd.ps1 deleted file mode 100644 index f943d60..0000000 --- a/src/internal/Export-HelpToMd.ps1 +++ /dev/null @@ -1,148 +0,0 @@ -function Export-HelpToMd { - [CmdletBinding()] - param ( - [Parameter(Mandatory, Position = 0, ValueFromPipeline)] - [PSCustomObject] - $HelpInfo - ) - - begin { - function GetText { - param ([string]$text, [string]$default) - - $text = $text.Trim() - if ([string]::IsNullOrEmpty($text)) { - if ([string]::IsNullOrEmpty($default)) { - $default = 'No description' - } - return $default - } - return $text - } - - function GetName ([PSCustomObject]$help) { - $lines = @() - $lines += '# {0}' -f $help.Name - $lines += [System.Environment]::NewLine - return $lines - } - - function GetDescription { - param ($description, [string]$noDesc) - - $description = $description.Description.Text | Out-String - $line = '{0}' -f (GetText $description $noDesc) - - return $line - } - - function GetParameterSet ([PSCustomObject]$help) { - $lines = @() - $setNum = 1 - - $lines += '## Parameters' - - foreach ($set in $help.syntax.syntaxItem) { - $lines += [System.Environment]::NewLine - $lines += '### Parameter Set {0}' -f $setNum - $lines += [System.Environment]::NewLine - - foreach ($param in $set.Parameter) { - $paramStringParts = @() - - $paramStringParts += '- `[{0}]`' -f (GetText $param.parameterValue 'switch') - - $paramStringParts += '**{0}**' -f $param.Name - - $paramStringParts += '_{0}_ ' -f (GetDescription -description $param -noDesc 'Parameter help description') - - $attributes = @() - if ($param.required -eq 'true') { $attributes += 'Mandatory' } - if ($param.pipelineInput -like '*ByValue*') { $attributes += 'ValueFromPipeline' } - - $paramStringParts += $attributes -join ', ' - - $lines += $paramStringParts -join ' ' - } - - $setNum++ - } - - return $lines - } - - function GetExample ([PSCustomObject]$help) { - $lines = @() - $exNum = 1 - - $lines += [System.Environment]::NewLine - $lines += '## Examples' - - foreach ($exampleList in $help.examples.example) { - foreach ($example in $exampleList) { - $lines += [System.Environment]::NewLine - $lines += '### Example {0}' -f $exNum - $lines += [System.Environment]::NewLine - - $lines += $example.remarks.Text.Where({ ![string]::IsNullOrEmpty($_) }) - $lines += [System.Environment]::NewLine - - $lines += '```powershell' - $lines += $example.code.Trim("`t") - $lines += '```' - - } - $exNum++ - } - - return $lines - } - - function GetLink ([PSCustomObject]$help, $Commands) { - if ($help.relatedLinks.count -gt 0) { - $lines = @() - - $lines += [System.Environment]::NewLine - $lines += '## Links' - $lines += [System.Environment]::NewLine - - foreach ($link in $help.relatedLinks) { - - foreach ($text in $link.navigationLink.linkText) { - - if ($text -match '\w{3,}-\w{3,}') { - $uri = $text - $lines += '- [{0}]({0}.md)' -f $uri - } - - if ($text -match 'images\/.+\.png') { - $uri = $text - $lines += '- [{0}]({0})' -f $uri - } - - } - foreach ($uri in $link.navigationLink.uri) { - if (![string]::IsNullOrEmpty($uri)) { - $lines += '- [{0}]({0})' -f $uri - } - } - } - - return $lines - } - } - } - - process { - - GetName $HelpInfo - GetDescription $HelpInfo $HelpInfo.Synopsis - GetParameterSet $HelpInfo - GetExample $HelpInfo - GetLink $HelpInfo - } - - end { - - } -} diff --git a/src/public/Get-SSLCertificate.ps1 b/src/public/Get-SSLCertificate.ps1 new file mode 100644 index 0000000..e9f7b06 --- /dev/null +++ b/src/public/Get-SSLCertificate.ps1 @@ -0,0 +1,141 @@ +function Get-SSLCertificate { + <# +.SYNOPSIS + Get the SSL Certificate for given host. +.DESCRIPTION + Open an SSL connection to the given host and read the presented server certificate. +.PARAMETER ComputerName + A hostname or Url of the server to retreive the certificate. +.PARAMETER Port + The port to connect to the remote server. +.PARAMETER OutSslStreamVariable + Stores SslStream connetion details from the command in the specified variable. +.NOTES + No validation check done. This command will trust all certificates presented. +.LINK + Invoke-HttpUnit +.LINK + Test-SSLCertificate +.INPUTS + String +.OUTPUTS + System.Security.Cryptography.X509Certificates.X509Certificate2 +.EXAMPLE + Get-SSLCertificate google.com + Thumbprint Subject EnhancedKeyUsageList + ---------- ------- -------------------- + 9B97772CC2C860B0D0663AD3ED34272FF927EDEE CN=*.google.com Server Authentication + + Return the certificate for google.com. +.EXAMPLE + $cert = Get-SSLCertificate expired.badssl.com + $cert.Verify() + False + + Verify a server certificate. You can use Test-SSLCertificate to validate the entire certificate chain. +.EXAMPLE + $cert = Get-SSLCertificate google.com -verbose + VERBOSE: Converting Uri to host string + VERBOSE: ComputerName = google.com + VERBOSE: Cipher: Aes256 strength 256 + VERBOSE: Hash: Sha384 strength 0 + VERBOSE: Key exchange: None strength 0 + VERBOSE: Protocol: Tls13 + + Write SslStream connection details to Verbose stream. +.EXAMPLE + PS> Get-SSLCertificate -ComputerName 'google.com' -OutSslStreamVariable sslStreamValue + Thumbprint Subject EnhancedKeyUsageList + ---------- ------- -------------------- + 5D3AD94714B07830A1BFB445F6F581AD0AC77689 CN=*.google.com Server Authentication + $sslStreamValue + CipherAlgorithm : Aes256 + CipherStrength : 256 + HashAlgorithm : Sha384 + HashStrength : 0 + KeyExchangeAlgorithm : None + KeyExchangeStrength : 0 + SslProtocol : Tls13 + + Stores SslStream connection details in the `$sslStreamValue` variable. +#> + + [CmdletBinding()] + param ( + [Parameter(Mandatory, Position = 0)] + [Alias('Address', 'Url')] + [string]$ComputerName, + + [Parameter(Position = 1)] + [ValidateRange(1, 65535)] + [int]$Port = 443, + + [Parameter()] + [string] + $OutSslStreamVariable + + ) + + $uri = $null + + if ([uri]::TryCreate($ComputerName, [System.UriKind]::RelativeOrAbsolute, [ref]$uri)) { + Write-Verbose "Converting Uri to host string" + if (![string]::IsNullOrEmpty($uri.Host)) { + $ComputerName = $uri.Host + } + } + + Write-Verbose "ComputerName = $ComputerName" + + $Certificate = $null + $TcpClient = New-Object -TypeName System.Net.Sockets.TcpClient + + try { + + $TcpClient.Connect($ComputerName, $Port) + $TcpStream = $TcpClient.GetStream() + + $SslStream = New-Object -TypeName System.Net.Security.SslStream -ArgumentList @($TcpStream, $true, $ServerCertificateCustomValidation_AlwaysTrust) + try { + + $SslStream.AuthenticateAsClient($ComputerName) + $Certificate = $SslStream.RemoteCertificate + + if ($PSBoundParameters.ContainsKey('OutSslStreamVariable')) { + $streamProperties = [PSCustomObject]@{ + CipherAlgorithm = $SslStream.CipherAlgorithm + CipherStrength = $SslStream.CipherStrength + HashAlgorithm = $SslStream.HashAlgorithm + HashStrength = $SslStream.HashStrength + KeyExchangeAlgorithm = $SslStream.KeyExchangeAlgorithm + KeyExchangeStrength = $SslStream.KeyExchangeStrength + SslProtocol = $SslStream.SslProtocol + } + + Set-Variable -Name $OutSslStreamVariable -Value $streamProperties -Scope Global + } + + "Cipher: {0} strength {1}" -f $SslStream.CipherAlgorithm, $SslStream.CipherStrength | Write-Verbose + "Hash: {0} strength {1}" -f $SslStream.HashAlgorithm, $SslStream.HashStrength | Write-Verbose + "Key exchange: {0} strength {1}" -f $SslStream.KeyExchangeAlgorithm, $SslStream.KeyExchangeStrength | Write-Verbose + "Protocol: {0}" -f $SslStream.SslProtocol | Write-Verbose + + } catch { + $_ + } finally { + $SslStream.Dispose() + } + } catch { + $_ + } finally { + $TcpClient.Dispose() + } + + if ($null -ne $Certificate) { + if ($Certificate -isnot [System.Security.Cryptography.X509Certificates.X509Certificate2]) { + $Certificate = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList $Certificate + } + + Write-Output $Certificate + } +} \ No newline at end of file diff --git a/src/public/Invoke-HttpUnit.ps1 b/src/public/Invoke-HttpUnit.ps1 index 64b10e0..9814558 100644 --- a/src/public/Invoke-HttpUnit.ps1 +++ b/src/public/Invoke-HttpUnit.ps1 @@ -1,5 +1,5 @@ function Invoke-HttpUnit { - <# +<# .SYNOPSIS A PowerShell port of httpunit. .DESCRIPTION diff --git a/src/public/Show-SSLCertificateUI.ps1 b/src/public/Show-SSLCertificateUI.ps1 new file mode 100644 index 0000000..ee9a044 --- /dev/null +++ b/src/public/Show-SSLCertificateUI.ps1 @@ -0,0 +1,42 @@ +function Show-SSLCertificateUI { + <# +.SYNOPSIS + Displays a dialog box with detailed information about the specified x509 certificate. +.DESCRIPTION + Displays a dialog box with detailed information about the specified x509 certificate. The dialog box includes buttons for installing or copying the certificate. +.PARAMETER Certificate + An X509Certificate2 certificate object. +.PARAMETER ComputerName + A hostname or Url of the server to retreive the certificate to test. +.PARAMETER Port + The port to connect to the remote server. +.NOTES + PowerShell processing is blocked until the certificates dialg box is closed. +.LINK + Get-SSLCertificate +.EXAMPLE + Get-SSLCertificate google.com | Show-SSLCertificateUI + + Launches a certificate dialogue box with details about the google.com certificate. +#> + [CmdletBinding(DefaultParameterSetName = 'Certificate')] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = 'Certificate')] + [Security.Cryptography.X509Certificates.X509Certificate2] + $Certificate, + + [Parameter(Mandatory, Position = 0, ParameterSetName = 'Host')] + [Alias('Address', 'Url')] + [string]$ComputerName, + + [Parameter(Position = 1, ParameterSetName = 'Host')] + [ValidateRange(1, 65535)] + [int]$Port + ) + + if ($PSBoundParameters.ContainsKey('ComputerName')) { + $Certificate = Get-SSLCertificate @PSBoundParameters + } + + [System.Security.Cryptography.X509Certificates.X509Certificate2UI]::DisplayCertificate($Certificate) +} \ No newline at end of file diff --git a/src/public/Test-SSLCertificate.ps1 b/src/public/Test-SSLCertificate.ps1 new file mode 100644 index 0000000..756a555 --- /dev/null +++ b/src/public/Test-SSLCertificate.ps1 @@ -0,0 +1,91 @@ +function Test-SSLCertificate { + <# +.SYNOPSIS + Test the validitiy of a given certificate. +.DESCRIPTION + Verifies the entire certificates chain from a certificate object or hostname. +.PARAMETER Certificate + An X509Certificate2 certificate object. +.PARAMETER RevocationMode + The Revocation Mode to use in validation. + NoCheck: No revocation check is performed on the certificate. + Offline: A revocation check is made using a cached certificate revocation list (CRL). + Online: A revocation check is made using an online certificate revocation list (CRL). +.PARAMETER ComputerName + A hostname or Url of the server to retreive the certificate to test. +.PARAMETER Port + The port to connect to the remote server. +.NOTES + Test-SSLCertificate takes into consideration the status of each element in the chain. +.LINK + Get-SSLCertificate +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.x509chain?view=net-8.0#remarks +.INPUTS + String, X509Certificates +.OUTPUTS + Bool +.EXAMPLE + Get-SSLCertificate google.com | Test-SSLCertificates + True + + Test the validity of the google SSL Certificate. +.EXAMPLE + Test-SSLCertificate expired.badssl.com -ErrorVariable validation + Test-SSLCertificate: Certificate failed chain validation: + A required certificate is not within its validity period when verifying against the current system clock or the timestamp in the signed file. + False + $validation.TargetObject.ChainElements.Certificate + Thumbprint Subject EnhancedKeyUsageList + ---------- ------- -------------------- + 404BBD2F1F4CC2FDEEF13AABDD523EF61F1C71F3 CN=*.badssl.com, OU… {Server Authentication, Client Authentication} + 339CDD57CFD5B141169B615FF31428782D1DA639 CN=COMODO RSA Domai… {Server Authentication, Client Authentication} + AFE5D244A8D1194230FF479FE2F897BBCD7A8CB4 CN=COMODO RSA Certi… + + Tests an invalid certificates and inspect the error in variable `$validation` for the certificate details. +#> + [CmdletBinding(DefaultParameterSetName = 'Certificate')] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = 'Certificate')] + [Security.Cryptography.X509Certificates.X509Certificate2] + $Certificate, + + [Parameter(Position = 1, ParameterSetName = 'Certificate')] + [Parameter(Position = 2, ParameterSetName = 'Host')] + [Security.Cryptography.X509Certificates.X509RevocationMode] + $RevocationMode = "Online", + + [Parameter(Mandatory, Position = 0, ParameterSetName = 'Host')] + [Alias('Address', 'Url')] + [string]$ComputerName, + + [Parameter(Position = 1, ParameterSetName = 'Host')] + [ValidateRange(1, 65535)] + [int]$Port = 443 + ) + + begin { + $Chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new() + $Chain.ChainPolicy.RevocationMode = $RevocationMode + } + + process { + + if ($PSBoundParameters.ContainsKey('ComputerName')) { + $Certificate = Get-SSLCertificate -ComputerName $ComputerName -Port $Port + } + + $buildResult = $Chain.Build($Certificate) + + if (! $buildResult) { + $exception = [Exception]::new(("Certificate failed chain validation:{0}{1}" -f [System.Environment]::NewLine, ($Chain.ChainStatus.StatusInformation -join [System.Environment]::NewLine))) + Write-Error -Exception $exception -Category SecurityError -ErrorId 100 -TargetObject $Chain + } + + $buildResult | Write-Output + } + + end { + + } +} \ No newline at end of file diff --git a/test/Get-SSLCertificate.Tests.ps1 b/test/Get-SSLCertificate.Tests.ps1 new file mode 100644 index 0000000..0994903 --- /dev/null +++ b/test/Get-SSLCertificate.Tests.ps1 @@ -0,0 +1,51 @@ +BeforeAll { + + Import-Module "$PSScriptRoot/../publish/httpunitPS" -Force + +} + +Describe 'Get-SSLCertificate' { + Context 'Valid' { + It "Returns ()" -ForEach @( + @{name = 'google.com'; expected = 'CN=*.google.com' } + @{name = 'https://google.com'; expected = 'CN=*.google.com' } + @{name = 'https://hsts.badssl.com/'; expected = 'CN=*.badssl.com' } + ) { + $cert = Get-SSLCertificate -ComputerName $name + $cert.Subject | Should -be $expected + } + + } + + Context 'OutSslStreamVariable' { + AfterEach { + Remove-Variable -Name sslStreamValue -ErrorAction SilentlyContinue + } + + It "Sets OutSslStreamVariable" { + $cert = Get-SSLCertificate -ComputerName 'google.com' -OutSslStreamVariable sslStreamValue + $sslStreamvalue | Should -Not -BeNullOrEmpty + $sslStreamvalue.CipherAlgorithm | Should -BeOfType 'Security.Authentication.CipherAlgorithmType' + $sslStreamvalue.CipherStrength | Should -BeIn @(0, 40, 56, 80, 128, 168, 192, 256) + $sslStreamvalue.HashAlgorithm | Should -BeOfType 'Security.Authentication.HashAlgorithmType' + $sslStreamvalue.HashStrength | Should -BeIn @(0, 128, 160) + $sslStreamvalue.KeyExchangeAlgorithm | Should -BeOfType 'Security.Authentication.ExchangeAlgorithmType' + $sslStreamvalue.KeyExchangeStrength | Should -BeIn @(0, 256, 512, 768, 1024, 2048) + $sslStreamvalue.SslProtocol | Should -BeOfType 'Security.Authentication.SslProtocols' + } + } + + Context 'Invalid' { + It "Returns ()" -ForEach @( + @{name = 'expired.badssl.com'; expected = 'CN=*.badssl.com, OU=PositiveSSL Wildcard, OU=Domain Control Validated' } + @{name = 'https://self-signed.badssl.com'; expected = 'CN=*.badssl.com, O=BadSSL, L=San Francisco, S=California, C=US' } + ) { + $cert = Get-SSLCertificate -ComputerName $name + $cert.Subject | Should -be $expected + } + } +} + +AfterAll { + Remove-Module httpunitPS +} \ No newline at end of file diff --git a/test/Test-SSLCertificate.Tests.ps1 b/test/Test-SSLCertificate.Tests.ps1 new file mode 100644 index 0000000..4f6fefc --- /dev/null +++ b/test/Test-SSLCertificate.Tests.ps1 @@ -0,0 +1,54 @@ +BeforeAll { + + Import-Module "$PSScriptRoot/../publish/httpunitPS" -Force + +} + +Describe 'Test-SSLCertificate' { + Context 'Valid (Certificate)' { + It "Returns True ()" -ForEach @( + @{name = 'google.com' } + @{name = 'https://google.com' } + @{name = 'https://hsts.badssl.com/' } + ) { + $result = Get-SSLCertificate -ComputerName $name | Test-SSLCertificate + $result | Should -BeTrue + } + } + + Context 'Valid (Hostname)' { + It "Returns True ()" -ForEach @( + @{name = 'google.com' } + @{name = 'https://google.com' } + @{name = 'https://hsts.badssl.com/' } + ) { + $result = Test-SSLCertificate -ComputerName $name + $result | Should -BeTrue + } + + It "Returns True (https://tls-v1-2.badssl.com:1012/)" { + $result = Test-SSLCertificate -ComputerName 'https://tls-v1-2.badssl.com' -Port 1012 + $result | Should -BeTrue + } + } + + Context 'Invalid' { + It "Returns False (expired.badssl.com)" { + $result = Get-SSLCertificate -ComputerName 'expired.badssl.com' | Test-SSLCertificate -ErrorAction SilentlyContinue + $result | Should -Not -BeTrue + $error[0].TargetObject.ChainElements.Certificate.Subject | Should -Contain 'CN=*.badssl.com, OU=PositiveSSL Wildcard, OU=Domain Control Validated' + $error[0].TargetObject.ChainStatus.Status | Should -Be 'NotTimeValid' + } + + It "Returns False (https://self-signed.badssl.com)" { + $result = Get-SSLCertificate -ComputerName 'https://self-signed.badssl.com' | Test-SSLCertificate -ErrorAction SilentlyContinue + $result | Should -Not -BeTrue + $error[0].TargetObject.ChainElements.Certificate.Subject | Should -Contain 'CN=*.badssl.com, O=BadSSL, L=San Francisco, S=California, C=US' + $error[0].TargetObject.ChainStatus.Status | Should -Be 'UntrustedRoot' + } + } +} + +AfterAll { + Remove-Module httpunitPS +} \ No newline at end of file diff --git a/version.json b/version.json index b3a00a5..92f9110 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "0.4.0", + "version": "0.5.0", "cloudBuild": { "buildNumber": { "enabled": true