Networkhorse.psm1



function Connect-SshKey
{
<#
.SYNOPSIS
Uses OpenSSH to generate a key and connect it to an ssh server.
 
.EXAMPLE
Connect-SshKey crowpi -UserName pi
#>


[CmdletBinding()] Param(
# The ssh server to connect to.
[Parameter(Position=0,Mandatory=$true)][string] $HostName,
# The remote username to use to connect.
[Alias('AsUserName')][string] $UserName = $env:UserName
)

$pubkeyfile = Join-Path $HOME .ssh id_rsa.pub
if(!(Test-Path $pubkeyfile -Type Leaf) -or !((Get-Item $pubkeyfile).Length))
{
    if(!(Get-Command ssh-keygen -Type Application -ErrorAction Ignore))
    {
        if($IsWindows)
        {
            if(Test-Path "$env:SystemRoot\system32\openssh\ssh-keygen.exe" -Type Leaf)
            {
                Set-Alias ssh-keygen "$env:SystemRoot\system32\openssh\ssh-keygen.exe"
            }
            else
            {
                throw 'Required "ssh-keygen" not found. To install, maybe run "Install-WindowsFeature OpenSSH.Client~~~~0.0.1.0"'
            }
        }
        throw 'Required "ssh-keygen" not found, install it to continue.'
    }
    ssh-keygen
}
Get-Content $pubkeyfile |
    ssh "$UserName@$HostName" 'cat >> .ssh/authorized_keys'

}

function ConvertTo-BasicAuthentication
{
<#
.SYNOPSIS
Produces a basic authentication header string from a credential.
 
.INPUTS
System.Management.Automation.PSCredential to convert to the Authorization HTTP header value.
 
.OUTPUTS
System.String to use as the Authorization HTTP header value.
 
.FUNCTIONALITY
HTTP
 
.LINK
https://tools.ietf.org/html/rfc1945#section-11.1
 
.LINK
http://stackoverflow.com/q/24672760/54323
 
.LINK
https://weblog.west-wind.com/posts/2010/Feb/18/NET-WebRequestPreAuthenticate-not-quite-what-it-sounds-like
 
.LINK
https://powershell.org/forums/topic/pscredential-parameter-help/
 
.EXAMPLE
Invoke-RestMethod https://example.com/api/items -Method Get -Headers @{Authorization=ConvertTo-BasicAuthentication (Get-Credential -Message 'Log in')}
 
Calls a REST method that requires Basic authentication on the first request (with no challenge-response support).
#>


[CmdletBinding()][OutputType([string])] Param(
# Specifies a user account to authenticate an HTTP request that only accepts Basic authentication.
[Parameter(Mandatory=$true,ValueFromPipeline=$true)]
[PSCredential][Management.Automation.Credential()]$Credential
)
Process
{
    return 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(
        "$($Credential.UserName):$($Credential.Password |ConvertFrom-SecureString -AsPlainText)"))
}

}

function ConvertTo-MultipartFormData
{
<#
.SYNOPSIS
Creates multipart/form-data to send as a request body.
 
.INPUTS
Any System.Collections.IDictionary type of key-value pairs to encode.
 
.OUTPUTS
System.Byte[] of encoded key-value data.
 
.FUNCTIONALITY
HTTP
 
.LINK
https://docs.microsoft.com/dotnet/api/system.net.http.multipartformdatacontent
 
.LINK
Invoke-WebRequest
 
.LINK
Invoke-RestMethod
 
.LINK
New-Guid
 
.EXAMPLE
@{ title = 'Name'; file = Get-Item avatar.png } |ConvertTo-MultipartFormData |Invoke-WebRequest $url -Method POST
 
Sends two fields, one of which is a file upload.
#>


[CmdletBinding()][OutputType([byte[]])] Param(
<#
The fields to pass, as a Hashtable or other dictionary.
Values of the System.IO.FileInfo type will be read, as for a file upload.
#>

[Parameter(Mandatory=$true,Position=0,ValueFromPipeline=$true)][Collections.IDictionary] $Fields
)
DynamicParam
{
    $boundary = "$(New-Guid)"
    $PSDefaultParameterValues['Invoke-WebRequest:ContentType'] = "multipart/form-data; boundary=$boundary"
    $PSDefaultParameterValues['Invoke-RestMethod:ContentType'] = "multipart/form-data; boundary=$boundary"
}
Begin
{
    $cmdletname = $MyInvocation.MyCommand.Name
    if((Get-Module Microsoft.PowerShell.Utility).Version -ge [version]6.1)
    {
        Write-Warning "Invoke-WebRequest and Invoke-RestMethod appear to natively support multipart/form-data."
    }
    try{[void][Net.Http.StringContent]}catch{Add-Type -AN System.Net.Http |Out-Null}
}
Process
{
    $content = New-Object Net.Http.MultipartFormDataContent $boundary
    foreach($field in $Fields.GetEnumerator())
    {
        if($field.Value -isnot [IO.FileInfo])
        {
            $content.Add([Net.Http.StringContent]$field.Value,$field.Key)
            Write-Verbose "$cmdletname : $($field.Key)=$($field.Value)"
        }
        else
        {
            Write-Verbose "$cmdletname : Adding file $($field.Value.FullName)"
            $content.Add((New-Object Net.Http.StreamContent ($field.Value.OpenRead())),$field.Key,$field.Value.Name)
        }
    }
    $content.Headers.GetEnumerator() |
        ForEach-Object {Write-Verbose "$cmdletname header : $($_.Key)=$($_.Value -join "`n`t")"}
    [Threading.Tasks.Task[byte[]]]$getbody = $content.ReadAsByteArrayAsync()
    $getbody.Wait()
    [byte[]]$body = $getbody.Result
    $content.Dispose()
    Write-Verbose "$cmdletname : Body is $($body.Length) bytes"
    return,$body
}
End
{
    $PSDefaultParameterValues.Remove('Invoke-WebRequest:ContentType')
    $PSDefaultParameterValues.Remove('Invoke-RestMethod:ContentType')
}

}

function Get-ContentSecurityPolicy
{
<#
.SYNOPSIS
Returns the content security policy at from the given URL.
 
.INPUTS
Microsoft.PowerShell.Commands.WebResponseObject from Invoke-WebRequest
or
any object with a Uri or Url property
 
.OUTPUTS
System.Management.Automation.PSCustomObject containing the parsed policy.
 
.FUNCTIONALITY
HTTP
 
.LINK
https://content-security-policy.com/
 
.LINK
Invoke-WebRequest
 
.EXAMPLE
Invoke-WebRequest http://example.org/ |Get-ContentSecurityPolicy
 
default-src : {http://example.org, http://example.net, 'self'}
script-src : {'self'}
img-src : {'self'}
report-uri : {http://example.com/csp}
#>


[CmdletBinding()][OutputType([Management.Automation.PSCustomObject])] Param(
# The URL to get the policy from.
[Parameter(ParameterSetName='Uri',Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true)]
[Alias('Url')][Uri] $Uri,
# The output from Invoke-WebRequest to parse the policy from.
[Parameter(ParameterSetName='Response',Mandatory=$true,ValueFromPipeline=$true)]
[Microsoft.PowerShell.Commands.WebResponseObject] $Response
)
Process
{
    if($Uri){$Response = Invoke-WebRequest $Uri -UseBasicParsing}
    if(!$Response.Headers.ContainsKey('Content-Security-Policy')){return}
    $csp = @{}
    $Response.Headers['Content-Security-Policy'] -split '\s*;\s*' |
        ForEach-Object {$directive,$values=$_ -split '\s+'; $csp[$directive]=[string[]]$values}
    return [pscustomobject]$csp
}

}

function Get-Dns
{
<#
.SYNOPSIS
Looks up DNS info, given a hostname or address.
 
.INPUTS
System.String of host names to look up.
 
.OUTPUTS
System.Net.IPHostEntry of host DNS entries, or
System.String of network addresses found.
 
.LINK
https://msdn.microsoft.com/library/ms143998.aspx
 
.EXAMPLE
Get-Dns www.google.com
 
HostName Aliases AddressList
-------- ------- -----------
www.google.com {} {172.217.10.132}
#>


[CmdletBinding()][OutputType([Net.IPHostEntry])][OutputType([string])] Param(
# A host name or address to look up.
[Parameter(Position=0,Mandatory=$true,ValueFromRemainingArguments=$true,ValueFromPipeline=$true)]
[Alias('Address','HostAddress','Name')][string[]] $HostName,
<#
Indicates that only the string versions of addresses belonging to the specified family should be returned.
"Unknown" returns all addresses.
#>

[Net.Sockets.AddressFamily] $OnlyAddresses
)

Process
{
    foreach ($h in $HostName)
    {
        $entry = [Net.Dns]::GetHostEntry($h)
        if(!$PSBoundParameters.ContainsKey('OnlyAddresses')) {$entry}
        elseif($OnlyAddresses -eq [Net.Sockets.AddressFamily]::Unspecified) {$entry.AddressList |ForEach-Object {$_.IPAddressToString}}
        else {$entry.AddressList |Where-Object AddressFamily -eq $OnlyAddresses |ForEach-Object {$_.IPAddressToString}}
    }
}

}

function Get-ServerCertificate
{
<#
.SYNOPSIS
Returns the certificate provided by the requested server.
 
.FUNCTIONALITY
TLS/SSL
 
.EXAMPLE
Get-ServerCertificate webcoder.info
 
Server : webcoder.info
Subject : CN=webcoder.info
Issuer : CN=R11, O=Let's Encrypt, C=US
Issued : 2024-07-06 15:51:57
Expires : 2024-10-04 15:51:56
Thumbprint : 363A8CBAB35E6F3254CBB52FE00D0E0E0B3606BC
Certificate : [Subject]...
Extensions : {[Subject Alternative Name, DNS Name=...
Chain : System.Security.Cryptography.X509Certificates.X509Chain
#>


[CmdletBinding()] Param(
# The server (hostname) to return the TLS/SSL certificate from.
[Parameter(Position=0,Mandatory=$true,ValueFromRemainingArguments=$true)][string[]] $Server
)
Begin
{
    if(!(Get-Command openssl -Type Application -ErrorAction Ignore))
    {
        if($IsWindows)
        {
            if(Test-Path "$env:SystemRoot\openssl.exe" -Type Leaf)
            {
                Set-Alias openssl "$env:SystemRoot\openssl.exe"
            }
            else
            {
                throw 'Required "openssl" is not installed.'
            }
        }
        else
        {
            throw 'Required "openssl" is not installed.'
        }
    }

    filter Get-ServerCertificate
    {
        [CmdletBinding()] Param(
        [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][string] $Server
        )
        $name = $Server -replace ':\d+\z'
        $serverPort = $Server -like '*:*' ? $Server : "${Server}:443"
        $serialized =  'Q' |openssl s_client -servername $name -connect $serverPort 2>NUL |Out-String
        $cert = New-Object X509Certificate2 (,[Text.Encoding]::UTF8.GetBytes($serialized))
        $chain = New-Object X509Chain
        [void]$chain.Build($cert)
        $ext = @{}
        $cert.Extensions |ForEach-Object {$ext.Add($_.Oid.FriendlyName, (New-Object AsnEncodedData $_.Oid, $_.RawData).Format($true).Trim())}
        return [pscustomobject]@{
            Server      = $Server
            Subject     = $cert.Subject
            AltNames    = $ext.ContainsKey('Subject Alternative Name') ? $ext['Subject Alternative Name'] : $null
            Issuer      = $cert.Issuer
            Issued      = $cert.NotBefore
            Expires     = $cert.NotAfter
            Thumbprint  = $cert.Thumbprint
            Certificate = $cert
            Extensions  = $ext
            Chain       = $chain
        }
    }
}
Process
{
    $Server |Get-ServerCertificate
}

}

function Get-SslDetails
{
<#
.SYNOPSIS
Enumerates the SSL protocols that the client is able to successfully use to connect to a server.
 
.INPUTS
System.String of hostname(s) to get SSL support and certificate details for.
 
.OUTPUTS
System.Management.Automation.PSCustomObject with certifcated details and
properties indicating support for SSL protocols with the cypher algorithm used
if supported or false if not supported.
 
.FUNCTIONALITY
HTTP
 
.LINK
https://msdn.microsoft.com/library/system.security.authentication.sslprotocols.aspx
 
.LINK
https://msdn.microsoft.com/library/system.net.security.sslstream.authenticateasclient.aspx
 
.EXAMPLE
Get-SslDetails -ComputerName www.google.com
 
ComputerName : www.google.com
Port : 443
KeyLength : 2048
SignatureAlgorithm : rsa-sha1
CertificateIssuer : Google Inc
CertificateExpires : 06/20/2018 06:22:00
Ssl2 : False
Ssl3 : False
Tls : Aes128
Tls11 : Aes128
Tls12 : Aes128
#>


[CmdletBinding()][OutputType([Management.Automation.PSCustomObject])] Param(
# The name of the remote computer to connect to.
[Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true,ValueFromPipeline=$true)]
[Alias('CN','Hostname')][string]$ComputerName,
# The remote port to connect to.
[Parameter(ValueFromPipelineByPropertyName=$true)][int]$Port = 443
)
#TODO: Add or replace dependency.
Begin {$protocols = Get-EnumValues.ps1 Security.Authentication.SslProtocols |Where-Object Name -notin 'None','Default' |Select-Object -ExpandProperty Name}
Process
{
    $result = [ordered]@{
        ComputerName         = $ComputerName
        Port                 = $Port
        KeyLength            = $null
        SignatureAlgorithm   = $null
        CertificateIssuer    = $null
        CertificateEffective = $null
        CertificateExpires   = $null
        Certificate          = $null
    }
    foreach($protocol in $protocols)
    {
        $socket = New-Object Net.Sockets.Socket Stream,Tcp
        $socket.Connect($ComputerName,$Port)
        try
        {
            $ssl = New-Object Net.Security.SslStream (New-Object Net.Sockets.NetworkStream $socket,$true),$true
            $ssl.AuthenticateAsClient($ComputerName,$null,$protocol,$false)
            if(!$result['Certificate'])
            {
                [Security.Cryptography.X509Certificates.X509Certificate2]$cert = $ssl.RemoteCertificate
                $result['KeyLength'] = $cert.PublicKey.Key.KeySize
                $result['SignatureAlgorithm'] = $cert.SignatureAlgorithm.FriendlyName
                $result['CertificateIssuer'] = $cert.GetNameInfo('SimpleName', $true)
                $result['CertificateEffective'] = $cert.NotBefore
                $result['CertificateExpires'] = $cert.NotAfter
                $result['Certificate'] = $cert
            }
            $result[$protocol] = $ssl.CipherAlgorithm
        }
        catch
        {
            $result[$protocol] = $false
        }
        finally
        {
            if($ssl -is [IDisposable]) {$ssl.Dispose()}
            if($socket -is [IDisposable]) {$socket.Dispose()}
        }
    }
    [pscustomobject]$result
}

}

function Save-PodcastEpisodes
{
<#
.SYNOPSIS
Downloads enclosures from a podcast feed.
 
.LINK
Save-WebRequest
 
.EXAMPLE
Save-PodcastEpisodes https://www.youlooknicetoday.com/rss -UseTitle
 
Downloads podcast episodes to the current directory.
#>


[CmdletBinding()] Param(
# The URL of the podcast feed.
[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][Alias('Url')][uri] $Uri,
# Episodes before this date will be ignored.
[datetime] $After,
# Episodes after this date will be ignored.
[datetime] $Before,
# Includes only the given number of initial episodes, by publish date.
[int] $First,
# Includes only the given number of most recent episodes, by publish date.
[int] $Last,
# Use episode titles for filenames.
[switch] $UseTitle,
# Downloads the episodes into a folder with the podcast name.
[switch] $CreateFolder
)
Process
{
    $channel = ([xml]((Invoke-WebRequest $Uri).Content)).rss.channel
    $channel |Format-List |Out-String |Write-Verbose
    $activity = "Downloading $($channel.title)"
    [object[]] $episodes = Invoke-RestMethod $Uri
    foreach($episode in $episodes) {$episode |Add-Member published ([datetime]$episode.pubDate)}
    if($PSBoundParameters.ContainsKey('After')) {[object[]] $episodes = $episodes |Where-Object published -gt $After}
    if($PSBoundParameters.ContainsKey('Before')) {[object[]] $episodes = $episodes |Where-Object published -lt $Before}
    if($PSBoundParameters.ContainsKey('First')) {[object[]] $episodes = $episodes |Sort-Object published |Select-Object -First $First}
    if($PSBoundParameters.ContainsKey('Last')) {[object[]] $episodes = $episodes |Sort-Object published |Select-Object -Last $Last}
    #TODO: Add or replace dependency.
    if($CreateFolder) {New-Item ($channel.title |ConvertTo-FileName.ps1) -ItemType Directory -EA Ignore |Push-Location}
    $i,$max = 0,($episodes.Count/100)
    foreach($episode in $episodes)
    {
        $episode |Format-List |Out-String |Write-Verbose
        $title = $episode.title |Select-Object -First 1
        Write-Progress $activity $title -curr $episode.pubDate -percent ($i++/$max)
        if(!$episode.PSObject.Properties.Match('enclosure').Count)
        {
            Write-Warning "No enclosure found for '$title', $($episode.pubDate)"
            continue
        }
        if($UseTitle)
        {
            $filename = if($episode.PSObject.Properties.Match('episode')) {$episode.episode + ' '} else {''}
            #TODO: Add or replace dependencies.
            $filename += $title |ConvertTo-FileName.ps1
            $filename += Split-Uri.ps1 $episode.enclosure.url -Extension
            Invoke-WebRequest $episode.enclosure.url -OutFile $filename
            (Get-Item $filename).CreationTime = $episode.published
        }
        else
        {
            Save-WebRequest $episode.enclosure.url -CreationTime $episode.published
        }
    }
    if($CreateFolder) {Pop-Location}
    Write-Progress $activity -Completed
}

}

function Save-WebRequest
{
<#
.SYNOPSIS
Downloads a given URL to a file, automatically determining the filename.
 
.INPUTS
Object with System.Uri property named Uri.
 
.FUNCTIONALITY
HTTP
 
.LINK
https://tools.ietf.org/html/rfc2183
 
.LINK
http://test.greenbytes.de/tech/tc2231/
 
.LINK
https://msdn.microsoft.com/library/system.net.mime.contentdisposition.filename.aspx
 
.LINK
https://msdn.microsoft.com/library/system.io.path.getinvalidfilenamechars.aspx
 
.LINK
Invoke-WebRequest
 
.LINK
Invoke-Item
 
.LINK
Move-Item
 
.EXAMPLE
Save-WebRequest https://www.irs.gov/pub/irs-pdf/f1040.pdf -Open
 
Saves f1040.pdf (or else a filename specified in the Content-Disposition header) and opens it.
#>


[CmdletBinding()][OutputType([void])] Param(
# The URL to download.
[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
[Alias('Url','Href','Src')][uri] $Uri,
# The directory to save the file into.
[string] $OutDirectory,
# Sets the creation time on the file to the given value.
[datetime] $CreationTime,
# Sets the creation time on the file to the given value.
[datetime] $LastWriteTime,
# When present, invokes the file after it is downloaded.
[switch] $Open
)
Begin
{
    function Get-FileName
    {
        [CmdletBinding()][OutputType([string])] Param(
        [Parameter(Position=0)][uri] $Uri
        )
        $uriFilename, $suggestion = $null, $null
        $response = Invoke-WebRequest $Uri -Method Head -SkipHttpErrorCheck -MaximumRedirection 0 -AllowInsecureRedirect -EA Ignore
        if([int]::DivRem($response.StatusCode, 100).Item1 -eq 3 -and $response.Headers['Location'].Count -gt 0)
        {
            return Get-FileName (New-Object uri $Uri,($response.Headers['Location'][0]))
        }
        if($response.Headers.ContainsKey('Content-Disposition') -and $response.Headers['Content-Disposition'].Count -gt 0)
        {
            [ContentDisposition] $disposition = $response.Headers['Content-Disposition'][0]
            $suggestion = $disposition.FileName |Split-Path -Leaf
        }
        #TODO: Add or replace dependencies.
        if($suggestion) {return $suggestion |ConvertTo-FileName.ps1}
        elseif($null -ne $Uri.Segments -and $Uri.Segments.Count -gt 0) {return $Uri.Segments[-1] |ConvertTo-FileName.ps1}
        elseif($Uri.Host) {return '{0}.saved' -f $Uri.Host |ConvertTo-FileName.ps1}
        else {return "$Uri.saved" |Split-Path -Leaf |ConvertTo-FileName.ps1}
    }
}
Process
{
    $filename = Get-FileName $Uri
    if($OutDirectory) {$filename = Join-Path $OutDirectory $filename}
    $response = Invoke-WebRequest $Uri -OutFile $filename -PassThru
    Write-Information "Saved to '$filename'"
    if($PSBoundParameters.ContainsKey('CreationTime')) {(Get-Item $filename).CreationTime = $CreationTime}
    if($PSBoundParameters.ContainsKey('LastWriteTime')) {(Get-Item $filename).LastWriteTime = $LastWriteTime}
    elseif($response.Headers['Last-Modified'] -is [string[]] -and $response.Headers['Last-Modified'].Count -gt 0)
    {
        (Get-Item $filename).LastWriteTime = [datetimeoffset]::Parse(($response.Headers['Last-Modified'][0])).LocalDateTime
    }
    if($Open) {Invoke-Item $filename}
}

}

function Show-HttpStatus
{
<#
.SYNOPSIS
Displays the HTTP status code info.
 
.FUNCTIONALITY
HTTP
 
.EXAMPLE
Show-HttpStatus ServiceUnavailable -AsCat
 
.EXAMPLE
Show-HttpStatus 200 -AsCat
#>


[CmdletBinding()] Param(
# The HTTP status code to describe.
[Parameter(Position=0,Mandatory=$true)][Net.HttpStatusCode] $Status,
# Render the code as a cat.
[switch] $AsCat
)
Begin
{
    if($AsCat -and !(Get-Command Out-ConsolePicture -ErrorAction Ignore))
    {
        Install-Module OutConsolePicture
    }
}
Process
{
    "$([int]$Status) $Status"
    "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/$([int]$Status)"
    if($AsCat)
    {
        $cat = "https://http.cat/$([int]$Status)"
        Out-ConsolePicture -Url $cat
        $cat
    }
}

}

function Test-HttpSecurity
{
<#
.SYNOPSIS
Scan sites using Mozilla's Observatory.
 
.INPUTS
System.String containing a URL host to check.
 
.OUTPUTS
System.Management.Automation.PSObject containing scan results.
 
.LINK
Invoke-RestMethod
 
.LINK
https://observatory.mozilla.org/
 
.EXAMPLE
Test-HttpSecurity www.example.net -Public
 
end_time : Thu, 22 Dec 2016 00:09:31 GMT
grade : F
hidden : False
likelihood_indicator : MEDIUM
response_headers : @{Accept-Ranges=bytes; Cache-Control=max-age=604800; Content-Encoding=gzip;
                       Content-Length=606; Content-Type=text/html; Date=Thu, 22 Dec 2016 00:09:31 GMT;
                       Etag="359670651+gzip"; Expires=Thu, 29 Dec 2016 00:09:31 GMT; Last-Modified=Fri, 09 Aug
                       2013 23:54:35 GMT; Server=ECS (sjc/4E3B); Vary=Accept-Encoding; X-Cache=HIT;
                       x-ec-custom-error=1}
scan_id : 2899791
score : 0
start_time : Thu, 22 Dec 2016 00:09:29 GMT
state : FINISHED
tests_failed : 6
tests_passed : 6
tests_quantity : 12
results : https://http-observatory.security.mozilla.org/api/v1/getScanResults?scan=2899791
host : www.example.net
 
.EXAMPLE
Test-HttpSecurity www.example.com -IncludeResults
 
end_time : Thu, 22 Dec 2016 16:17:17 GMT
grade : F
hidden : True
likelihood_indicator : MEDIUM
response_headers : @{Accept-Ranges=bytes; Cache-Control=max-age=604800; Content-Encoding=gzip;
                       Content-Length=606; Content-Type=text/html; Date=Thu, 22 Dec 2016 16:17:17 GMT;
                       Etag="359670651+gzip"; Expires=Thu, 29 Dec 2016 16:17:17 GMT; Last-Modified=Fri, 09 Aug
                       2013 23:54:35 GMT; Server=ECS (sjc/4E5C); Vary=Accept-Encoding; X-Cache=HIT;
                       x-ec-custom-error=1}
scan_id : 2903851
score : 0
start_time : Thu, 22 Dec 2016 16:17:16 GMT
state : FINISHED
tests_failed : 6
tests_passed : 6
tests_quantity : 12
results : @{content-security-policy=; contribute=; cookies=; cross-origin-resource-sharing=;
                       public-key-pinning=; redirection=; referrer-policy=; strict-transport-security=;
                       subresource-integrity=; x-content-type-options=; x-frame-options=; x-xss-protection=}
host : www.example.com
#>


[CmdletBinding()][OutputType([psobject])] Param(
# Hostnames to scan, e.g. www.example.org
[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][string[]]$Hosts,
# Indicates a new scan should be performed, rather than returning a cached one.
[Alias('Rescan')][switch]$Force,
<#
Indicates the scan results may be posted publically.
By default, scans are unlisted.
#>

[switch]$Public,
# Indicates the detailed scan results should be fetched rather than simply providing a URL for them.
[Alias('Details','Results','FetchResults')][switch]$IncludeResults,
# The number of milliseconds to wait between polling the hostnames for scan completion.
[int]$PollingInterval = 1753,
# The address of the Observatory web service.
[Uri]$Endpoint = 'https://http-observatory.security.mozilla.org/api/v1'
)
Process
{
    $scan = @{}
    Write-Progress 'Mozilla Observatory Scan' 'Initiating scans'
    $i,$max = 0,($Hosts.Count/99.99)
    $Hosts |ForEach-Object {
        Write-Progress 'Mozilla Observatory Scan' 'Initiating scans' -CurrentOperation $_ -PercentComplete ($i++/$max)
        $scan.Add($_,(Invoke-RestMethod "$Endpoint/analyze?host=$_" -Body @{hidden=!$Public;rescan=$Force} -Method Post))
    }

    while([string[]]$pending = $scan.Keys |Where-Object {$scan.$_.state -like '*ING' -or
        !(Get-Member state -InputObject $scan.$_ -MemberType Properties)})
    {
        Write-Progress 'Mozilla Observatory Scan' "Waiting $PollingInterval ms" -PercentComplete ($pending.Count/$max)
        Start-Sleep -Milliseconds $PollingInterval
        $pending |ForEach-Object {
            Write-Progress 'Mozilla Observatory Scan' "Checking $_" -PercentComplete ($pending.Count/$max)
            $scan.$_ = Invoke-RestMethod "$Endpoint/analyze?host=$_"
        }
    }
    Write-Progress 'Mozilla Observatory Scan' -Completed

    $scan.Keys |ForEach-Object {
        $results = "$Endpoint/getScanResults?scan=$($scan.$_.scan_id)"
        if($IncludeResults) {$results = Invoke-RestMethod $results}
        Add-Member results $results -InputObject $scan.$_
        Add-Member host $_ -InputObject $scan.$_ -PassThru
    }
}

}

function Trace-WebRequest
{
<#
.SYNOPSIS
Provides details about a retrieving a URI.
 
.NOTES
TODO: Add support for other Invoke-WebRequest parameters.
 
.INPUTS
System.Uri to retrieve.
 
.FUNCTIONALITY
HTTP
 
.EXAMPLE
Trace-WebRequest g.co/p3phelp -SkipHeaders -SkipContent
 
g.co is CN=*.google.com from CN=WR2, O=Google Trust Services, C=US
Valid 05/12/2025 01:42:58 to 08/04/2025 01:42:57
GET https://g.co/p3phelp
HTTP/1.1 302 Found
Following redirect to https://support.google.com/accounts/answer/151657?hl=en
support.google.com is CN=*.google.com from CN=WR2, O=Google Trust Services, C=US
Valid 05/12/2025 01:42:58 to 08/04/2025 01:42:57
GET https://support.google.com/accounts/answer/151657?hl=en
HTTP/1.1 301 MovedPermanently
Following redirect to https://support.google.com/accounts/topic/3382252?hl=en&visit_id=638845176026805186-2907418293&rd=1
GET https://support.google.com/accounts/topic/3382252?hl=en&visit_id=638845176026805186-2907418293&rd=1
HTTP/1.1 301 MovedPermanently
Following redirect to https://support.google.com/accounts/?hl=en&visit_id=638845176026805186-2907418293&rd=2&topic=3382252
GET https://support.google.com/accounts/?hl=en&visit_id=638845176026805186-2907418293&rd=2&topic=3382252
HTTP/1.1 200 OK
#>


[CmdletBinding()] Param(
# The URL to retrieve.
[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
[Alias('Url','Href','Src')][uri] $Uri,
# The HTTP method verb to use.
[HttpMethod] $Method = 'GET',
# A file to log the request to.
[string] $LogFile,
# Indicates headers shouldn't be output.
[switch] $SkipHeaders,
# Indicates content shouldn't be output.
[switch] $SkipContent
)
Begin
{
    $certhost = @{}
    #TODO: Add or replace dependency.
    Import-CharConstants.ps1 :lock: :outbox_tray: :inbox_tray: :information_source: 'timer clock' -AsEmoji

    filter Get-HttpStatusColor
    {
        [CmdletBinding()][OutputType([ConsoleColor])] Param(
        [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][int] $Code
        )
        switch([int]::DivRem($Code, 100).Item1)
        {
            1 {return [ConsoleColor]::White}
            2 {return [ConsoleColor]::Green}
            3 {return [ConsoleColor]::Blue}
            4 {return [ConsoleColor]::Red}
            5 {return [ConsoleColor]::DarkRed}
        }
    }

    function Trace-Uri
    {
        [CmdletBinding()] Param(
        # The URL to retrieve.
        [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
        [Alias('Url','Href','Src')][uri] $Uri,
        # The HTTP method verb to use.
        [HttpMethod] $Method = 'GET'
        )
        if(!$certhost.Contains($Uri.Host))
        {
            $certinfo = Get-ServerCertificate $Uri.Host
            $certhost[$Uri.Host] = $certinfo
            Write-Information "$lock $($Uri.Host) is $($certinfo.Subject) from $($certinfo.Issuer)" #-fg Magenta
            Write-Information "${timer clock} Valid $($certinfo.Issued) to $($certinfo.Expires)" #-fg DarkMagenta
        }
        $request = New-Object Net.Http.HttpRequestMessage -ArgumentList $Method, $Uri
        $requestLine, $requestRawHeaders = "$Method $Uri", ($request.Headers.ToString())
        Write-Verbose $requestLine
        Write-Verbose $requestRawHeaders
        Write-Information "$outbox_tray $requestLine" #-fg DarkGreen
        #Write-Information $requestRawHeaders #-fg DarkGray
        if($LogFile)
        {@"
###
$Method $Uri
$requestRawHeaders
"@
 |Add-Content $LogFile
        }
        Write-Debug $requestLine
        $StatusCode = 0
        Invoke-WebRequest -Uri $Uri -SkipHttpErrorCheck -MaximumRedirection 0 -AllowInsecureRedirect -EA Ignore |
            Import-Variables.ps1 #TODO: Add or replace dependency.
        if(!$StatusCode)
        {
            if($LogFile)
            {@"
###
# Response: $($_.Message)
"@
 |Add-Content $LogFile
            }
            return
        }
        $statusLine, $rawHeaders = ($RawContent -replace '(?s)\r?\n\r?\n.*\z') -split '\r?\n',2
        if($null -eq $rawHeaders) {$rawHeaders = ''}
        Write-Verbose $statusLine
        Write-Verbose $rawHeaders
        Write-Information "$inbox_tray $statusLine" #-fg (Get-HttpStatusColor $StatusCode)
        if(!$SkipHeaders) {Write-Information $rawHeaders} # -fg Gray}
        if(!$SkipContent -and $Content) {Write-Information $Content} # -fg White}
        if($LogFile)
        {@"
###
# Response:
$RawContent
"@
 |Add-Content $LogFile
        }
        if([int]::DivRem($StatusCode, 100).Item1 -eq 3)
        {
            foreach($location in $Headers.Location |ForEach-Object {New-Object Uri $Uri,$_})
            {
                Write-Information "$information_source Following redirect to $location" #-fg DarkBlue
                Trace-Uri $location
            }
        }
    }
}
Process
{
    if(!$Uri.IsAbsoluteUri -and [uri]::IsWellFormedUriString("https://$Uri", 'Absolute'))
    {
        [uri] $Uri = "https://$Uri"
    }
    Trace-Uri -Uri $Uri -Method $Method
}

}
Export-ModuleMember -Function Connect-SshKey,ConvertTo-BasicAuthentication,ConvertTo-MultipartFormData,Get-ContentSecurityPolicy,Get-Dns,Get-ServerCertificate,Get-SslDetails,Save-PodcastEpisodes,Save-WebRequest,Show-HttpStatus,Test-HttpSecurity,Trace-WebRequest