src/SecurityTxtToolkit.psm1

#Requires -Version 5.1
New-Variable -Scope 'Script' -Name 'UserAgent' -Option 'Constant' -Value 'SecurityTxtToolkit/1.3.0 (https://github.com/rhymeswithmogul/security-txt-toolkit)'

Function Get-SecurityTxtFile {
    [Alias('gsectxt')]
    [OutputType([String])]
    Param(
        [Parameter(Mandatory, Position=0, ValueFromPipelineByPropertyName)]
        [Alias('DomainName','Host','HostName','Name','Uri','Url')]
        [ValidateNotNullOrEmpty()]
        [String] $Domain
    )

    # Below are the parameters we will be using for Invoke-WebRequest.
    $Params = @{
        'Method'          = 'GET'
        'UseBasicParsing' = $true
        'UserAgent'       = $script:UserAgent
    }

    $WebRequest = $null
    ForEach ($Uri in @(
        "https://$Domain/.well-known/security.txt",
        "https://$Domain/security.txt",
        "http://$Domain/.well-known/security.txt",
        "http://$Domain/security.txt")
    ) {
        Write-Verbose "Downloading $Uri"
        $WebRequest = Invoke-WebRequest @Params -Uri $Uri -ErrorAction SilentlyContinue
        If ($null -ne $WebRequest -and $WebRequest.StatusCode -eq 200) {
            Break
        }
    }
    If (-Not $WebRequest -Or $WebRequest.StatusCode -NotLike '2*') {
        Write-Error -Message "No `"security.txt`" file was found at $Domain."
        Return $null
    }

    If ($WebRequest.BaseResponse.RequestMessage.RequestUri.Scheme -eq 'http') {
        Write-Warning -Message "The `"security.txt`" file for $Domain could not be downloaded via HTTPS."
    }

    If ($WebRequest.BaseResponse.RequestMessage.RequestUri.AbsolutePath -eq 'security.txt') {
        Write-Warning -Message "The `"security.txt`" file for $Domain was found in the root folder, but not in the .well-known folder."
    }

    # Check to make sure this file was served via HTTP 1.0 or higher.
    If ($WebRequest.RawContent.Substring(0,6) -Cne 'HTTP/1') {
        Write-Warning -Message "The `"security.txt`" file for $Domain was not downloaded by HTTP 1.0 or newer."
    }

    # Check to make sure that this file has the correct content type.
    If ($WebRequest.Headers.'Content-Type' -NotMatch 'text\/plain(;\s*charset=[Uu][Tt][Ff]-8)?') {
        Write-Warning -Message "The `"security.txt`" file for $Domain was served with the incorrect MIME type."
    }

    Return $WebRequest.Content
}

Function Test-SecurityTxtFile {
    [Alias('tsectxt')]
    [CmdletBinding(DefaultParameterSetName='Online')]
    [OutputType([PSCustomObject])]
    Param(
        [Parameter(ParameterSetName='Online', Position=0, ValueFromPipelineByPropertyName)]
        [Alias('DomainName','Host','HostName','Name','Uri','Url')]
        [ValidateNotNullOrEmpty()]
        [String] $Domain,

        [Parameter(ParameterSetName='Offline', Mandatory, Position=0, ValueFromPipeline)]
        [AllowNull()]
        [String[]] $InputObject,

        [Parameter(ParameterSetName='Offline', Position=1)]
        [Uri] $TestCanonicalUri
    )

    $Return = [PSCustomObject]@{
        'For' = 'stdin'
        'IsValid' = $true
        'IsCanonical' = $false
        'Acknowledgements' = @()
        'Canonical' = @()
        'Contact' = @()
        'Encryption' = @()
        'Expires' = $null
        'Hiring' = @()
        'Policy' = @()
        'PreferredLanguages' = @()
        'IsSigned' = $false
        'IsSignedBy' = $null
        'HasGoodSignature' = $false
    }

    #region Get "security.txt" file.
    If ($PSCmdlet.ParameterSetName -eq 'Offline') {
        $securityTxt = $input
        Write-Debug -Message "Parsing a $($securityTxt.Length)-character string."
    }
    Else {
        $Return.For = $Domain

        # Below are the parameters we will be using for Invoke-WebRequest.
        $Params = @{
            'Method'          = 'GET'
            'UseBasicParsing' = $true
            'UserAgent'       = $script:UserAgent
        }

        $WebRequest = $null
        ForEach ($Uri in @(
            "https://$Domain/.well-known/security.txt",
            "https://$Domain/security.txt",
            "http://$Domain/.well-known/security.txt",
            "http://$Domain/security.txt")
        ) {
            Write-Verbose "Downloading $Uri"
            $WebRequest = Invoke-WebRequest @Params -Uri $Uri -ErrorAction SilentlyContinue
            If ($WebRequest.StatusCode -eq 200) {
                Break
            }
        }
        If (-Not $WebRequest -Or $WebRequest.StatusCode -NotLike '2*') {
            Write-Error -Message "No `"security.txt`" file was found at $Domain."
            Return $null
        }

        If ($WebRequest.BaseResponse.RequestMessage.RequestUri.Scheme -eq 'http') {
            Write-Error -Message "The `"security.txt`" file for $Domain could not be downloaded via HTTPS."
            $Return.IsValid = $false
        }

        If ($WebRequest.BaseResponse.RequestMessage.RequestUri.AbsolutePath -eq 'security.txt') {
            Write-Warning -Message "The `"security.txt`" file for $Domain was found in the root folder, but not in the .well-known folder."
        }

        # Check to make sure this file was served via HTTP 1.0 or higher.
        If ($WebRequest.RawContent.Substring(0,6) -Cne 'HTTP/1') {
            Write-Error -Message "The `"security.txt`" file for $Domain was not downloaded by HTTP 1.0 or newer."
            $Return.IsValid = $false
        }

        # Check to make sure that this file has the correct content type.
        If ($WebRequest.Headers.'Content-Type' -NotMatch 'text\/plain(;\s*charset=[Uu][Tt][Ff]-8)?') {
            Write-Error -Message "The `"security.txt`" file for $Domain was served with the incorrect MIME type."
            $Return.IsValid = $false
        }

        $securityTxt = $WebRequest.Content
    }
    #endregion

    #region Read all fields from the file.
    $securityTxt -Split "`r?`n+" `
      | Select-String -Pattern '^[A-Za-z-]+:\s+' `
      | ForEach-Object {
            $FieldName, $FieldValue = $_ -Split ":\s+",2
            Switch ($FieldName)
            {
                'Acknowledgments' {
                    $AckUri = [Uri]$FieldValue
                    If ($AckUri.Scheme -eq 'http') {
                        Write-Error -Message 'An Acknowledgments URI uses insecure HTTP.'
                        $Return.IsValid = $false
                    }
                    $Return.Acknowledgements += $AckUri
                }

                'Canonical' {
                    $CanonicalUri = [Uri]$FieldValue
                    If ($CanonicalUri.Scheme -eq 'http') {
                        Write-Error -Message 'A Canonical URI uses insecure HTTP.'
                        $Return.IsValid = $false
                    }
                    
                    # We will check for canonicity by testing against both the
                    # $Uri parameter and the URI used to request this resource
                    # (i.e., after an HTTP 301 redirects). The RFC leaves the
                    # behavior intentionally vague, so we will leave the final
                    # decision up to the human instead of the code.
                    # See https://github.com/securitytxt/security-txt/issues/217
                    #
                    # If we have already established canonicity, we don't need
                    # to continue checking URIs. Some "security.txt" files may
                    # have a lot of them.
                    If (-Not $Return.IsCanonical) {
                        Write-Verbose "The URL requested was: $Uri"
                        Write-Verbose "The URL used was: $($WebRequest.BaseResponse.RequestMessage.RequestUri)"
                        Write-Verbose "This Canonical URL is: $canonicalUri"
                        If ($PSCmdlet.ParameterSetName -eq 'Offline') {
                            Write-Verbose "The testing URL is: $TestCanonicalUri"
                        }
                        If (($PSCmdlet.ParameterSetName -eq 'Online' -and (
                                $CanonicalUri -eq $Uri -or
                                $CanonicalUri -eq $WebRequest.BaseResponse.RequestMessage.RequestUri
                            )) `
                            -or $CanonicalUri -eq $TestCanonicalUri
                        ) {
                            Write-Verbose "^^ MATCHED!"
                            $Return.IsCanonical = $true
                        }
                    }
                    $Return.Canonical += $CanonicalUri
                }

                'Contact' {
                    $ContactUri = [Uri]$FieldValue
                    If ($ContactUri.Scheme -eq 'http') {
                        Write-Error -Message 'A Contact URI uses insecure HTTP.'
                        $Return.IsValid = $false
                    }

                    $Return.Contact += $ContactUri
                }
                
                'Encryption' {
                    $KeyUri = [Uri]$FieldValue
                    If ($KeyUri.Scheme -eq 'http') {
                        Write-Error -Message 'An Encryption URI uses insecure HTTP.'
                        $Return.IsValid = $false
                    }
                    $Return.Encryption += $KeyUri
                }

                'Expires' {
                    If ($null -ne $Return.Expires) {
                        Write-Error -Message 'The Expires field is specified more than once!'
                        $Return.IsValid = $false
                    }

                    Try {
                        $Return.Expires = Get-Date $FieldValue
                        If ((Get-Date) -gt $Return.Expires) {
                            Write-Error -Message "This file expired at $($Return.Expires)!"
                            $Return.IsValid = $false
                        }
                    }
                    Catch {
                        Write-Error -Message 'The Expires field could not be parsed.'
                        $Return.IsValid = $false
                    }
                }
                
                'Hiring' {
                    $JobsUri = [Uri]$FieldValue
                    If ($JobsUri.Scheme -eq 'http') {
                        Write-Error -Message 'A Hiring URI uses insecure HTTP.'
                        $Return.IsValid = $false
                    }
                    $Return.Hiring += $JobsUri
                }

                'Preferred-Languages' {
                    If ($Return.PreferredLanguages.Count -ne 0) {
                        Write-Error -Message 'The Preferred-Languages field was specified more than once.'
                        $Return.IsValid = $false
                    }
                    $Return.PreferredLanguages += $FieldValue -Split ','
                }
                
                'Policy' {
                    $PolicyUri = [Uri]$FieldValue
                    If ($PolicyUri.Scheme -eq 'http') {
                        Write-Error -Message 'A Hiring URI uses insecure HTTP.'
                        $Return.IsValid = $false
                    }
                    $Return.Policy += $PolicyUri
                }
            }
    }
    #endregion

    #region Check for mandatory fields, expiration, and signatures.
    If (-Not $Return.IsCanonical) {
        Write-Error -Message 'A matching Canonical field was not found. This file should not be trusted for this domain.'
        # However, we're not going to call the file invalid.
    }
    If ($null -eq $Return.Contact) {
        Write-Error -Message 'The mandatory Contact field was not found.'
        $Return.IsValid = $false
    }
    If ($null -eq $Return.Expires) {
        Write-Error -Message 'The mandatory Expires field was not found.'
        $Return.IsValid = $false
    }

    $GnuPGApp = (Get-Command -Name 'gpg' | Select-Object -First 1)
    If ($null -ne $GnuPGApp) {
        Try {
            $VerifyStdInFile  = New-TemporaryFile
            $VerifyStdoutFile = New-TemporaryFile
            $VerifyStderrFile = New-TemporaryFile
            Set-Content -Path $VerifyStdinFile -Value $SecurityTxt

            $SigningProcess = @{
                'FilePath' = $GnuPGApp.Source
                'ArgumentList' = '--verify'
                'LoadUserProfile' = $true    # to use the user's $env:GNUPGHOME
                'RedirectStandardInput'  = $VerifyStdinFile
                'RedirectStandardError'  = $VerifyStderrFile
                'RedirectStandardOutput' = $VerifyStdoutFile
                'Wait' = $true
            }
            Start-Process @SigningProcess

            # On my system, `gpg --verify` emits to stderr. Not sure why.
            # Just in case it emits to stdout on your system, we'll pull from
            # the stdout file in case stderr is blank.
            $VerifyResults = Get-Content $VerifyStderrFile
            Write-Debug "Error stream from gpg: $VerifyResults"
            If ($null -eq $VerifyResults) {
                $VerifyResults = Get-Content $VerifyStdoutFile
                Write-Debug "Error stream null. Switching to output stream: $VerifyResults"
            }

            $Return.IsSigned = $null -ne (Select-String -InputObject $VerifyResults -Pattern 'signature from')
            If ($Return.IsSigned) {
                $Return.IsSignedBy = ($VerifyResults -Replace 'gpg:\s*','')
            }
            $Return.HasGoodSignature = $null -ne (Select-String -InputObject $VerifyResults -Pattern 'good signature from')
        }
        Finally {
            Remove-Item -Path $VerifyStdinFile  -Force -ErrorAction Ignore
            Remove-Item -Path $VerifyStdoutFile -Force -ErrorAction Ignore
            Remove-Item -Path $VerifyStderrFile -Force -ErrorAction Ignore
        }

    }
    #endregion

    Return $Return
}

Function New-SecurityTxtFile {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact='Low')]
    [Alias('nsectxt', 'Set-SecurityTxtFile', 'ssectxt')]
    [OutputType([String], ParameterSetName='ToPipeline')]
    [OutputType([void],   ParameterSetName='ToFile')]
    Param(
        [Parameter(Position=0, ParameterSetName='ToFile')]
        [IO.File] $OutFile,

        [Alias('Acknowledgements')]
        [Uri[]] $Acknowledgments,
        
        [Alias('Uri','Url')]
        [ValidateNotNullOrEmpty()]
        [Uri[]] $Canonical,
        
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [Uri[]] $Contact,
        
        [Uri[]] $Encryption,
        
        [ValidateNotNullOrEmpty()]
        [DateTime] $Expires,

        [Uri[]] $Hiring,
        
        [Uri[]] $Policy,
        
        [Alias('Languages', 'Preferred-Languages')]
        [String[]] $PreferredLanguages,

        [Switch] $DoNotSign
    )

    $Lines = @(
        '# This is a "security.txt" file that complies with RFC 9116:'
        '# <https://www.rfc-editor.org/rfc/rfc9116>'
        '#',
        '# This file was made with SecurityTxtToolkit:',
        '# <https://github.com/rhymeswithmogul/security-txt-toolkit>',
        ''
    )

    ForEach ($value in $Acknowledgments) {
        $Lines += "Acknowledgments: $value"
    }

    ForEach ($value in $Canonical) {
        $Lines += "Canonical: $value"
    }

    ForEach ($value in $Contact) {
        $Lines += "Contact: $value"
    }

    ForEach ($value in $Encryption) {
        $Lines += "Encryption: $value"
    }

    If ($null -eq $Expires) {
        $Expires = (Get-Date).AddYears(1)
    }
    $Lines += "Expires: $(Get-Date $Expires -Format 'yyyy-MM-ddTHH:mm:ssK')"

    ForEach ($value in $Hiring) {
        $Lines += "Hiring: $value"
    }

    ForEach ($value in $Policy) {
        $Lines += "Policy: $value"
    }

    If ($PreferredLanguages) {
        $Lines += "Preferred-Languages: $($PreferredLanguages -Join ', ')"
    }

    $FileContent = $Lines -Join "`r`n"

    Try {
        If ($DoNotSign) {
            Throw
        }

        $UnsignedFile = New-TemporaryFile
        Set-Content -Path $UnsignedFile -Value "$FileContent`r`n" -Encoding utf8

        $SignedFile   = New-TemporaryFile
        $SigningProcess = @{
            'FilePath' = (Get-Command 'gpg').Source
            'ArgumentList' = '--clear-sign'
            'RedirectStandardInput' = $UnsignedFile
            'RedirectStandardOutput' = $SignedFile
            'Wait' = $true
        }
        Start-Process @SigningProcess

        If ($PSCmdlet.ParameterSetName -eq 'ToFile' -and $PSCmdlet.ShouldProcess($OutFile, 'Create clear-signed file')) {
            Move-Item -Path $SignedFile -Destination $OutFile
        }
        Else {
            Get-Content -Path $SignedFile
        }
    }
    Catch {
        If ($PSCmdlet.ParameterSetName -eq 'ToFile' -and $PSCmdlet.ShouldProcess($OutFile, 'Create unsigned file')) {
            Set-Content -Path $OutFile -Value $FileContent -Encoding utf8
        }
        Else {
            $FileContent
        }
    }
    Finally {
        # If the temporary files still exist, delete them.
        # We're using calls to Get-Variable due to strict mode being set.
        If (Get-Variable UnsignedFile -ErrorAction Ignore) {
            Remove-Item -Path $UnsignedFile -Force -ErrorAction Ignore
        }
        If (Get-Variable SignedFile -ErrorAction Ignore) {
            Remove-Item -Path $SignedFile   -Force -ErrorAction Ignore
        }
    }
}