Networkhorse.psm1
|
function Add-ScopeLevel { <# .SYNOPSIS Convert a scope level to account for another call stack level. .DESCRIPTION For scripts that need to get or set a variable of a specific scope so that it disappears at the end of a block/function/script, or so that it persists globally, this calculates the additional call level added by that script. .INPUTS System.String containing the desired level. .OUTPUTS System.String containing the calculated level (Global or an integer). .LINK Stop-ThrowError .LINK Get-PSCallStack .LINK about_Scopes .FUNCTIONALITY PowerShell .EXAMPLE Add-ScopeLevel Local 1 .EXAMPLE Add-ScopeLevel 3 4 .EXAMPLE Add-ScopeLevel Global Global #> [CmdletBinding()][OutputType([string])] Param( # The requested scope from the caller of the caller of this script. # Global, Local, Private, Script, or a positive integer. [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][string] $Scope ) Process { if($Scope -match '\A\d+\z') {return "$(1+[int]$Scope)"} switch($Scope) { Global {return 'Global'} # the module scope seems to implicitly add a level Local {return "1"} Private {return "1"} Script { $stack = Get-PSCallStack for($i = 2; $i -lt $stack.Length; $i++) { if($stack[$i].Command -and $stack[$i].FunctionName -like '<ScriptBlock>*') {return "$($i-1)"} } Stop-ThrowError 'Unable to find Script scope' -Argument Scope } } } } function ConvertTo-FileName { <# .SYNOPSIS Returns a valid and safe filename from a given string. .INPUTS System.String containing a filename that may contain invalid characters. .OUTPUTS System.String containing a filename without any invalid characters. .FUNCTIONALITY Unicode .EXAMPLE 'app*.log' |ConvertTo-FileName app_.log .EXAMPLE 'one|two-<three>' |ConvertTo-FileName -CurrentPlatformOnly one_two-_three_ .EXAMPLE 'app-${value}.config' |ConvertTo-FileName Ascii -ExcludeChars '%','$','{','}','`' app-_value_.config #> [CmdletBinding()][OutputType([string])] Param( # Allows limiting the filename to either ASCII or Basic Multilingual Plane characters, if specified. [ValidateSet('Bmp', 'Ascii')][string] $OutputBlock, # The character to use to replace a range of invalid characters. [text.rune] $Replacement = '_'[0], # Characters to include (overrides exclusions). [ValidateNotNull()][char[]] $IncludeChars = @(), # Characters to exclude. [ValidateNotNull()][char[]] $ExcludeChars = @(), # Runes to include (overrides excludes). [ValidateNotNull()][text.rune[]] $IncludeRunes = @(), # Runes to exclude. [ValidateNotNull()][text.rune[]] $ExcludeRunes = @(), # Indicates that only characters invalid for the current platform should be excluded by default, # otherwise invalid characters from any platform will be excluded by default (unless overridden). [switch] $CurrentPlatformOnly, # The string value to sanitize for use as a filename. [Parameter(Mandatory=$true,ValueFromPipeline=$true)][string] $InputObject ) Begin { [char[]] $skipchars = $CurrentPlatformOnly ? ([IO.Path]::GetInvalidFileNameChars()) : @('"', '*', '/', ':', '<', '>', '?', '\', '|') function Copy-Rune { [CmdletBinding()] Param( [Parameter(Position=0,Mandatory=$true)][text.rune] $Rune ) Set-Variable skipping $false -Scope 1 (Get-Variable value -Scope 1).Value.Append($Rune) |Out-Null } function Skip-Rune { $skipping = Get-Variable skipping -Scope 1 if(!$skipping.Value) { $skipping.Value = $true (Get-Variable value -Scope 1).Value.Append($Replacement) |Out-Null } } } Process { $skipping, $value = $false, (New-Object Text.StringBuilder) foreach ($rune in $InputObject.EnumerateRunes()) { if($rune -in $IncludeRunes) {Copy-Rune $rune} elseif($rune -in $ExcludeRunes) {Skip-Rune} elseif($rune.IsBmp) { [char] $char = $rune.Value if($char -in $IncludeChars) {Copy-Rune $rune} elseif($char -in $ExcludeChars) {Skip-Rune} elseif($char -in $skipchars -or [char]::IsControl($char)) {Skip-Rune} elseif($OutputBlock -eq 'Ascii' -and !$rune.IsAscii) {Skip-Rune} else {Copy-Rune $rune} } elseif($OutputBlock -eq 'Bmp') {Skip-Rune} else {Copy-Rune $rune} } return $value.ToString() } } function Import-Variables { <# .SYNOPSIS Creates local variables from a data row or dictionary (hashtable). .INPUTS System.Collections.IDictionary with keys and values to import as variables, or System.Management.Automation.PSCustomObject with properties to import as variables. .FUNCTIONALITY PowerShell .EXAMPLE if($line -match '\AProject\("(?<TypeGuid>[^"]+)"\)') {Import-Variables $Matches} Copies $Matches.TypeGuid to $TypeGuid if a match is found. .EXAMPLE Import-Csv |ForEach-Object {$_ |Import-Variables; Write-Host "Properties: $Name $Id $Description"} Copies field values into $ProductID, $Name, and $ListPrice. .EXAMPLE if($env:ComSpec -match '^(?<ComPath>.*?\\)(?<ComExe>[^\\]+$)'){Import-Variables $Matches -Verbose} Sets $ComPath and $ComExe from the regex captures if the regex matches. .EXAMPLE Invoke-RestMethod https://api.github.com/ |Import-Variables ; Invoke-RestMethod $emojis_url Sets variables from the fields returned by the web service: $current_user_url, $emojis_url, &c. Then fetches the list of GitHub emojis. #> [CmdletBinding()][OutputType([void])] Param( <# A hash of string names to any values to set as variables, or a DataRow or object with properties to set as variables. Works with DataRows. #> [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][PSObject] $InputObject, # The type of object members to convert to variables. [Alias('Type')][Management.Automation.PSMemberTypes] $MemberType = 'Properties', # The scope of the variables to create. [string] $Scope = 'Local', # Indicates that created variables should be hidden from child scopes. [switch] $Private ) Begin { $Scope = Add-ScopeLevel $Scope $sv = if($Private) {@{Scope=$Scope;Option='Private'}} else {@{Scope=$Scope}} } Process { $isDict = $InputObject -is [Collections.IDictionary] [string[]]$vars = if($isDict) {$InputObject.Keys |Where-Object {$_ -is [string]}} else {Get-Member -InputObject $InputObject -MemberType $MemberType |Select-Object -ExpandProperty Name} if(!$vars){return} Write-Verbose "Importing $($vars.Count) $(if($isDict){'keys'}else{"$MemberType properties"}): $vars" foreach($var in $vars) {Set-Variable $var $InputObject.$var @sv} } } 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 Security.Cryptography.X509Certificates.X509Certificate2 (,[Text.Encoding]::UTF8.GetBytes($serialized)) $chain = New-Object Security.Cryptography.X509Certificates.X509Chain [void]$chain.Build($cert) $ext = @{} $cert.Extensions |ForEach-Object {$ext.Add($_.Oid.FriendlyName, (New-Object Security.Cryptography.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) -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 $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} elseif($null -ne $Uri.Segments -and $Uri.Segments.Count -gt 0) {return $Uri.Segments[-1] |ConvertTo-FileName} elseif($Uri.Host) {return '{0}.saved' -f $Uri.Host |ConvertTo-FileName} else {return "$Uri.saved" |Split-Path -Leaf |ConvertTo-FileName} } } 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. [Net.Http.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 = @{} $lock = "$([char]0xD83D)$([char]0xDD12)$([char]0xFE0F)" # :lock:/LOCK $outbox_tray = "$([char]0xD83D)$([char]0xDCE4)$([char]0xFE0F)" # :outbox_tray:/OUTBOX TRAY $inbox_tray = "$([char]0xD83D)$([char]0xDCE5)$([char]0xFE0F)" # :inbox_tray:/INBOX TRAY $information_source = "$([char]0x2139)$([char]0xFE0F)" # :information_source:/INFORMATION SOURCE ${timer clock} = "$([char]0x23F2)$([char]0xFE0F)" # TIMER CLOCK 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. [Net.Http.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 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 |