src/SecurityTxtToolkit.psm1

#Requires -Version 5.1
Set-StrictMode -Version 3.0

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
    }

    #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'       = 'SecurityTxtToolkit/1.0 (https://github.com/rhymeswithmogul/security-txt-toolkit)'
        }

        $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 -Not $WebRequest.BaseResponse.IsSuccessStatusCode) {
            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
                    }
                    
                    If (($PSCmdlet.ParameterSetName -eq 'Online' -and $CanonicalUri -eq $WebRequest.BaseResponse.RequestMessage.RequestUri)`
                        -or ($CanonicalUri -eq $TestCanonicalUri)
                    ) {
                        $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
    }

    # We can't assume that the user will have the GnuPG tools installed, so we're just going
    # to check for the existence of something that looks like a signature and call it a day.
    # Validating the signature is an exercise left to the reader.
    #
    # TODO: figure out how to validate the signature.
    If ($securityTxt -Match 'BEGIN PGP SIGNED MESSAGE') {
        $Return.IsSigned = $true
    }
    #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 draft-foudil-securitytxt-12:'
        '# <https://datatracker.ietf.org/doc/html/draft-foudil-securitytxt-12>'
        '#',
        '# 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
        }
    }
}