SophosFirewall.ActiveThreatResponse.psm1

#requires -Version 5.1
#requires -Modules SophosFirewall.Core

<#
        .SYNOPSIS
        Manages Active Threat Response settings on Sophos Firewall: Sophos X-Ops threat feeds (ATP) and third-party threat feeds.
 
        .DESCRIPTION
        PowerShell module for the PROTECT > Active threat response area of the Sophos XGS / SFOS 22.0
        XML API.
 
        This module provides functions to read and update the device-wide ATP (Sophos X-Ops threat
        feeds) singleton, including its HostException and ThreatException lists, and to create, read,
        update, and delete ThirdPartyFeed objects.
 
        All functions support pipeline input, filtering, and connection context management.
        Use Connect-SfosFirewall once, then call functions without connection parameters.
 
        .EXAMPLE
        # Connect and read the device-wide ATP configuration
        Connect-SfosFirewall -Firewall "192.168.1.1" -Credential (Get-Credential) -SkipCertificateCheck
        Get-SfosATPSettings
 
        .EXAMPLE
        # List every configured third-party threat feed
        Get-SfosThirdPartyFeed
 
        .NOTES
        Module Name: SophosFirewall.ActiveThreatResponse
        Author: Jan Weis
        Homepage: https://www.it-explorations.de
        Version: 1.0.0
        PowerShell Version: 5.1+
 
        Dependencies:
        - SophosFirewall.Core module (provides Connect-SfosFirewall, Invoke-SfosApi, etc.)
 
        API Compatibility:
        - Sophos SFOS 22.0
        - Sophos XGS Firewall Series
 
        Total Functions: 10
        - 6 ATP (Sophos X-Ops threat feeds) functions, including HostException/ThreatException members
        - 4 ThirdPartyFeed functions
 
        Behaviour that differs from the vendor documentation was measured against a live appliance
        and is recorded in the .NOTES of the affected function. See the module README for the
        cmdlet table and known behaviour/limitations.
 
        .LINK
        https://docs.sophos.com/nsg/sophos-firewall/22.0/API/
#>


#region ATP

<#
        .SYNOPSIS
        Retrieves the ATP (Sophos X-Ops threat feeds) settings from the Sophos Firewall.
 
        .DESCRIPTION
        Queries the Sophos Firewall XML API for the ATP singleton (PROTECT > Active threat
        response > Sophos X-Ops threat feeds - the wire element and doc folder are still named
        ATP, a rebranding leftover from "Advanced Threat Protection" [measured/doc]). There is
        exactly one instance of this element per firewall. By default the cmdlet returns a
        PowerShell-friendly object. Use -AsXml to return the raw XML node.
 
        .PARAMETER Session
        A session object returned by Connect-SfosFirewall, or the name of a session
        registered with Connect-SfosFirewall -Name. Overrides the stored default
        connection context; any of -Firewall/-Port/-Username/-Password/
        -SkipCertificateCheck supplied explicitly still wins over it. Enables piping
        between firewalls, e.g. Get-SfosIPHost -Session $fw1 | New-SfosIPHost -Session fw2.
 
        .PARAMETER Firewall
        Sophos Firewall hostname or IP address. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Port
        Management/API port number (typically 4444). If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Username
        Username for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Password
        Password for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER SkipCertificateCheck
        Skips SSL certificate validation for the API call.
 
        .PARAMETER AsXml
        Returns the raw XML node instead of a PowerShell-friendly object.
 
        .OUTPUTS
        PSCustomObject (default). System.Xml.XmlElement when -AsXml is specified.
 
        .EXAMPLE
        # Read the device-wide ATP configuration
        Get-SfosATPSettings
 
        .NOTES
        Minimum supported PowerShell version: 5.1
        Measured live: the response carries no <Status> data field for this entity - fields
        are named ThreatProtectionStatus/InspectContent/Policy, not Status - so Core's generic
        Assert-SfosApiReturnSuccess/Get-SfosApiStatus heuristic needs no special-casing here
        (unlike IPSSwitch, whose single field actually is named <Status>). A live-provoked
        error (invalid Policy value) confirmed the status node sits at the expected
        /Response/ATP/Status[@code] path and is picked up correctly.
        HostException/Host and ThreatException/Threat come back as empty (no wrapper element
        at all) when no exceptions are configured; this cmdlet returns @() for both in that
        case, never $null.
 
        .LINK
        https://docs.sophos.com/nsg/sophos-firewall/22.0/API/PROTECT/Active%20threat%20response/ATP/operations/SophosX-Opsthreatfeeds.html
 
        .LINK
        Set-SfosATPSettings
#>

function Get-SfosATPSettings {
    # PSUseSingularNouns is suppressed on purpose: <ATP> settings form one singleton
    # configuration object (Sophos X-Ops threat feeds), not a plural container - the same
    # reasoning as the WebFilterSettings precedent.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    [CmdletBinding()]
    param(
        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,
        [object]$Session,

        [switch]$AsXml
    )

    $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters

    $inner = '<Get><ATP></ATP></Get>'

    try {
        $response = Invoke-SfosApi -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -InnerXml $inner -SkipCertificateCheck:$params.SkipCertificateCheck -ErrorAction Stop
    }
    catch {
        throw "Failed to retrieve ATP settings: $($_.Exception.Message)"
    }

    $XmlResponse = [xml]$response.Content
    Assert-SfosApiReturnSuccess -Xml $XmlResponse -ObjectName 'ATP' -Action 'get'

    $node = $XmlResponse.SelectSingleNode('/Response/ATP')
    if (-not $node) {
        throw 'ATP settings could not be retrieved from the firewall.'
    }

    if ($AsXml) {
        return $node
    }

    $hostNodes = @($node.SelectNodes('HostException/Host'))
    $threatNodes = @($node.SelectNodes('ThreatException/Threat'))

    return [PSCustomObject]@{
        ThreatProtectionStatus = [string]$node.ThreatProtectionStatus
        InspectContent         = [string]$node.InspectContent
        Policy                 = [string]$node.Policy
        HostExceptionList      = @($hostNodes | ForEach-Object -Process { [string]$_.InnerText })
        ThreatExceptionList    = @($threatNodes | ForEach-Object -Process { [string]$_.InnerText })
    }
}

<#
        .SYNOPSIS
        Updates the ATP (Sophos X-Ops threat feeds) settings on the Sophos Firewall.
 
        .DESCRIPTION
        Updates the device-wide ATP singleton using the Sophos Firewall XML API. Reads the
        current object first and resends every field, overriding only what the caller
        explicitly passed (read-modify-write - SFOS replaces the whole entity on update).
        Supports ShouldProcess; use -WhatIf to preview.
 
        This is a device-wide security switch (advanced threat protection / IPS threat feed
        enforcement for the whole appliance) - test any change against a maintenance window
        and verify the result.
 
        .PARAMETER ThreatProtectionStatus
        'Enable' or 'Disable' [doc]. If omitted, the existing value is kept.
 
        .PARAMETER InspectContent
        'all' or 'untrusted' [doc]. If omitted, the existing value is kept. Both values
        confirmed live.
 
        .PARAMETER Policy
        'Log Only' or 'Log and Drop' [doc - the wire uses this exact UI wording, not the
        sample XML's 'alert'/'drop' placeholders, both confirmed live]. If omitted, the
        existing value is kept.
 
        .PARAMETER HostException
        Complete replacement list of HostException entries [doc]. Each entry must be the
        Name of an existing IPHost object on the firewall - an arbitrary string is rejected
        with a field-precise 501 naming /ATP/HostException/Host [measured]. If omitted, the
        existing list is kept. Pass an empty array to clear the list - measured to be a
        genuine full replace for this field (not append-only): omitting the wrapper entirely
        or sending an explicit empty <HostException/> both clear a previously set list, and a
        duplicate entry sent twice is silently de-duplicated by the firewall.
 
        .PARAMETER ThreatException
        Complete replacement list of ThreatException entries [doc]. Unlike HostException, an
        arbitrary string value is accepted here (no existing-object requirement) [measured].
        If omitted, the existing list is kept. Pass an empty array to clear the list - same
        full-replace behaviour as HostException.
 
        .PARAMETER Session
        A session object returned by Connect-SfosFirewall, or the name of a session
        registered with Connect-SfosFirewall -Name. Overrides the stored default
        connection context; any of -Firewall/-Port/-Username/-Password/
        -SkipCertificateCheck supplied explicitly still wins over it. Enables piping
        between firewalls, e.g. Get-SfosIPHost -Session $fw1 | New-SfosIPHost -Session fw2.
 
        .PARAMETER Firewall
        Sophos Firewall hostname or IP address. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Port
        Management/API port number (typically 4444). If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Username
        Username for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Password
        Password for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER SkipCertificateCheck
        Skips SSL certificate validation for the API call.
 
        .OUTPUTS
        None. Throws an exception if the update fails.
 
        .EXAMPLE
        # Switch content inspection from untrusted-only to all traffic, leaving everything else unchanged
        Set-SfosATPSettings -InspectContent all
 
        .NOTES
        Minimum supported PowerShell version: 5.1
        Verified live end to end: baseline captured (ThreatProtectionStatus=Enable,
        InspectContent=untrusted, Policy='Log and Drop'), written back unchanged (round trip
        byte-identical), InspectContent toggled to 'all' and reverted, Policy toggled to
        'Log Only' and reverted to 'Log and Drop' - every write answered code 200 and the
        final read matched the captured baseline exactly. See the task report for the raw
        responses.
 
        .LINK
        https://docs.sophos.com/nsg/sophos-firewall/22.0/API/PROTECT/Active%20threat%20response/ATP/operations/SophosX-Opsthreatfeeds.html
 
        .LINK
        Get-SfosATPSettings
#>

function Set-SfosATPSettings {
    # PSUseSingularNouns is suppressed on purpose: see Get-SfosATPSettings.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [ValidateSet('Enable', 'Disable')]
        [string]$ThreatProtectionStatus,

        [ValidateSet('all', 'untrusted')]
        [string]$InspectContent,

        [ValidateSet('Log Only', 'Log and Drop')]
        [string]$Policy,

        [string[]]$HostException,

        [string[]]$ThreatException,

        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,

        [object]$Session
    )

    $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters
    $bp = $PSBoundParameters

    $current = Get-SfosATPSettings -Firewall $params.Firewall `
        -Port $params.Port `
        -Username $params.Username `
        -Password $params.Password `
        -SkipCertificateCheck:$params.SkipCertificateCheck

    $targetThreatProtectionStatus = if ($bp.ContainsKey('ThreatProtectionStatus')) { $ThreatProtectionStatus } else { $current.ThreatProtectionStatus }
    $targetInspectContent = if ($bp.ContainsKey('InspectContent')) { $InspectContent } else { $current.InspectContent }
    $targetPolicy = if ($bp.ContainsKey('Policy')) { $Policy } else { $current.Policy }
    # @() wraps the whole if/else: a one-element array from a branch unrolls to a scalar
    # on assignment (measured on PS 5.1).
    $targetHostException = @(if ($bp.ContainsKey('HostException')) { $HostException } else { $current.HostExceptionList })
    $targetThreatException = @(if ($bp.ContainsKey('ThreatException')) { $ThreatException } else { $current.ThreatExceptionList })

    if (-not $PSCmdlet.ShouldProcess("ATP settings on $($params.Firewall)", 'Update')) {
        return
    }

    $policyEsc = ConvertTo-SfosXmlEscaped -Text $targetPolicy

    $hostXml = ''
    if ($targetHostException.Count -gt 0) {
        $hostItems = foreach ($item in $targetHostException) {
            $itemEsc = ConvertTo-SfosXmlEscaped -Text $item
            "<Host>$itemEsc</Host>"
        }
        $hostXml = "<HostException>$($hostItems -join '')</HostException>"
    }

    $threatXml = ''
    if ($targetThreatException.Count -gt 0) {
        $threatItems = foreach ($item in $targetThreatException) {
            $itemEsc = ConvertTo-SfosXmlEscaped -Text $item
            "<Threat>$itemEsc</Threat>"
        }
        $threatXml = "<ThreatException>$($threatItems -join '')</ThreatException>"
    }

    $inner = @"
<Set operation="update">
  <ATP>
    <ThreatProtectionStatus>$targetThreatProtectionStatus</ThreatProtectionStatus>
    <InspectContent>$targetInspectContent</InspectContent>
    <Policy>$policyEsc</Policy>
    $hostXml
    $threatXml
  </ATP>
</Set>
"@


    try {
        $response = Invoke-SfosApi -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -InnerXml $inner -SkipCertificateCheck:$params.SkipCertificateCheck -ErrorAction Stop
    }
    catch {
        throw "Failed to update ATP settings: $($_.Exception.Message)"
    }

    $XmlResponse = [xml]$response.Content
    Assert-SfosApiReturnSuccess -Xml $XmlResponse -ObjectName 'ATP' -Action 'update'
}

<#
        .SYNOPSIS
        Adds an IPHost exception to the ATP settings on the Sophos Firewall.
 
        .DESCRIPTION
        Adds an entry to the ATP HostException list using the Sophos Firewall XML API. Reads
        the current ATP object first and resends it complete with the new host appended
        (read-modify-write, per Set-SfosATPSettings). Supports ShouldProcess; use -WhatIf to
        preview.
 
        .PARAMETER HostName
        Name of an existing IPHost object on the firewall to except from ATP inspection
        [measured: an arbitrary string that is not the name of an existing IPHost object is
        rejected with a field-precise 501 on /ATP/HostException/Host]. Mandatory; accepts
        pipeline input by value or by property name (aliased 'Host' so
        Get-SfosIPHost | Add-SfosATPHostException binds).
 
        .PARAMETER Session
        A session object returned by Connect-SfosFirewall, or the name of a session
        registered with Connect-SfosFirewall -Name. Overrides the stored default
        connection context; any of -Firewall/-Port/-Username/-Password/
        -SkipCertificateCheck supplied explicitly still wins over it. Enables piping
        between firewalls, e.g. Get-SfosIPHost -Session $fw1 | New-SfosIPHost -Session fw2.
 
        .PARAMETER Firewall
        Sophos Firewall hostname or IP address. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Port
        Management/API port number (typically 4444). If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Username
        Username for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Password
        Password for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER SkipCertificateCheck
        Skips SSL certificate validation for the API call.
 
        .OUTPUTS
        None. Throws an exception if the update fails.
 
        .EXAMPLE
        Add-SfosATPHostException -HostName 'WebServer01'
 
        .NOTES
        Minimum supported PowerShell version: 5.1
        Verified live: created a test IPHost, added it as a HostException (code 200), read
        back and confirmed present under HostException/Host, sent the exact same host a
        second time and confirmed the firewall de-duplicates rather than storing it twice, then
        removed it and confirmed the ATP object returned to its original captured baseline
        (byte-identical). See the task report for the raw responses.
 
        .LINK
        https://docs.sophos.com/nsg/sophos-firewall/22.0/API/PROTECT/Active%20threat%20response/ATP/operations/SophosX-Opsthreatfeeds.html
 
        .LINK
        Get-SfosATPSettings
 
        .LINK
        Remove-SfosATPHostException
#>

function Add-SfosATPHostException {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Host')]
        [string]$HostName,

        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,

        [object]$Session
    )

    begin {
        $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters
    }

    process {
        $current = Get-SfosATPSettings -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -SkipCertificateCheck:$params.SkipCertificateCheck

        if (-not $PSCmdlet.ShouldProcess("ATP HostException '$HostName' on $($params.Firewall)", 'Add')) {
            return
        }

        $newList = @($current.HostExceptionList) + $HostName

        Set-SfosATPSettings -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -SkipCertificateCheck:$params.SkipCertificateCheck `
            -HostException $newList `
            -Confirm:$false
    }
}

<#
        .SYNOPSIS
        Removes an IPHost exception from the ATP settings on the Sophos Firewall.
 
        .DESCRIPTION
        Removes an entry from the ATP HostException list using the Sophos Firewall XML API.
        Reads the current ATP object first, throws if the host is not currently listed, and
        otherwise resends the ATP object complete with the host filtered out
        (read-modify-write, per Set-SfosATPSettings). Reads the object back afterwards and
        throws if the host is still present - guards against the append-only-list pattern
        seen elsewhere in this API, even though this entity was measured NOT to have that
        problem (see .NOTES). Supports ShouldProcess; use -WhatIf to preview.
 
        .PARAMETER HostName
        Name of the IPHost object to remove from the HostException list. Mandatory; accepts
        pipeline input by value or by property name (aliased 'Host').
 
        .PARAMETER Session
        A session object returned by Connect-SfosFirewall, or the name of a session
        registered with Connect-SfosFirewall -Name. Overrides the stored default
        connection context; any of -Firewall/-Port/-Username/-Password/
        -SkipCertificateCheck supplied explicitly still wins over it. Enables piping
        between firewalls, e.g. Get-SfosIPHost -Session $fw1 | New-SfosIPHost -Session fw2.
 
        .PARAMETER Firewall
        Sophos Firewall hostname or IP address. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Port
        Management/API port number (typically 4444). If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Username
        Username for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Password
        Password for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER SkipCertificateCheck
        Skips SSL certificate validation for the API call.
 
        .OUTPUTS
        None. Throws an exception if the update fails or the host is still present afterwards.
 
        .EXAMPLE
        Remove-SfosATPHostException -HostName 'WebServer01'
 
        .NOTES
        Minimum supported PowerShell version: 5.1
        Verified live: unlike the URLList/SecurityPolicyList append-only lists documented
        elsewhere in this project, ATP's HostException list is a genuine full replace on
        update - omitting the <HostException> wrapper entirely, or sending an explicit empty
        <HostException/>, both cleared a previously set entry and the object read back to
        exactly its pre-test baseline. This cmdlet's post-removal read-back is kept anyway as
        a defensive guard, per project convention for member-removal cmdlets.
 
        .LINK
        https://docs.sophos.com/nsg/sophos-firewall/22.0/API/PROTECT/Active%20threat%20response/ATP/operations/SophosX-Opsthreatfeeds.html
 
        .LINK
        Get-SfosATPSettings
 
        .LINK
        Add-SfosATPHostException
#>

function Remove-SfosATPHostException {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Host')]
        [string]$HostName,

        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,

        [object]$Session
    )

    begin {
        $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters
    }

    process {
        $current = Get-SfosATPSettings -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -SkipCertificateCheck:$params.SkipCertificateCheck

        if (@($current.HostExceptionList) -notcontains $HostName) {
            throw "The ATP HostException '$HostName' was not found."
        }

        if (-not $PSCmdlet.ShouldProcess("ATP HostException '$HostName' on $($params.Firewall)", 'Remove')) {
            return
        }

        $newList = @($current.HostExceptionList | Where-Object -FilterScript { $_ -ne $HostName })

        Set-SfosATPSettings -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -SkipCertificateCheck:$params.SkipCertificateCheck `
            -HostException $newList `
            -Confirm:$false

        $after = Get-SfosATPSettings -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -SkipCertificateCheck:$params.SkipCertificateCheck

        if (@($after.HostExceptionList) -contains $HostName) {
            throw "Failed to remove ATP HostException '$HostName': the firewall reported success but the host is still present."
        }
    }
}

<#
        .SYNOPSIS
        Adds a threat exception to the ATP settings on the Sophos Firewall.
 
        .DESCRIPTION
        Adds an entry to the ATP ThreatException list using the Sophos Firewall XML API. Reads
        the current ATP object first and resends it complete with the new threat identifier
        appended (read-modify-write, per Set-SfosATPSettings). Supports ShouldProcess; use
        -WhatIf to preview.
 
        .PARAMETER Threat
        Threat identifier text to except from ATP enforcement [doc]. Unlike HostException, an
        arbitrary string is accepted here - no existing-object requirement was found
        [measured]. Mandatory; accepts pipeline input by value or by property name.
 
        .PARAMETER Session
        A session object returned by Connect-SfosFirewall, or the name of a session
        registered with Connect-SfosFirewall -Name. Overrides the stored default
        connection context; any of -Firewall/-Port/-Username/-Password/
        -SkipCertificateCheck supplied explicitly still wins over it. Enables piping
        between firewalls, e.g. Get-SfosIPHost -Session $fw1 | New-SfosIPHost -Session fw2.
 
        .PARAMETER Firewall
        Sophos Firewall hostname or IP address. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Port
        Management/API port number (typically 4444). If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Username
        Username for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Password
        Password for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER SkipCertificateCheck
        Skips SSL certificate validation for the API call.
 
        .OUTPUTS
        None. Throws an exception if the update fails.
 
        .EXAMPLE
        Add-SfosATPThreatException -Threat 'C2/Generic-A'
 
        .NOTES
        Minimum supported PowerShell version: 5.1
        Verified live: added an arbitrary threat identifier string (code 200), read back and
        confirmed present under ThreatException/Threat, then removed it and confirmed the ATP
        object returned to its original captured baseline (byte-identical). See the task
        report for the raw responses.
 
        .LINK
        https://docs.sophos.com/nsg/sophos-firewall/22.0/API/PROTECT/Active%20threat%20response/ATP/operations/SophosX-Opsthreatfeeds.html
 
        .LINK
        Get-SfosATPSettings
 
        .LINK
        Remove-SfosATPThreatException
#>

function Add-SfosATPThreatException {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Threat,

        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,

        [object]$Session
    )

    begin {
        $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters
    }

    process {
        $current = Get-SfosATPSettings -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -SkipCertificateCheck:$params.SkipCertificateCheck

        if (-not $PSCmdlet.ShouldProcess("ATP ThreatException '$Threat' on $($params.Firewall)", 'Add')) {
            return
        }

        $newList = @($current.ThreatExceptionList) + $Threat

        Set-SfosATPSettings -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -SkipCertificateCheck:$params.SkipCertificateCheck `
            -ThreatException $newList `
            -Confirm:$false
    }
}

<#
        .SYNOPSIS
        Removes a threat exception from the ATP settings on the Sophos Firewall.
 
        .DESCRIPTION
        Removes an entry from the ATP ThreatException list using the Sophos Firewall XML API.
        Reads the current ATP object first, throws if the threat is not currently listed, and
        otherwise resends the ATP object complete with the threat filtered out
        (read-modify-write, per Set-SfosATPSettings). Reads the object back afterwards and
        throws if the threat is still present. Supports ShouldProcess; use -WhatIf to preview.
 
        .PARAMETER Threat
        Threat identifier text to remove from the ThreatException list. Mandatory; accepts
        pipeline input by value or by property name.
 
        .PARAMETER Session
        A session object returned by Connect-SfosFirewall, or the name of a session
        registered with Connect-SfosFirewall -Name. Overrides the stored default
        connection context; any of -Firewall/-Port/-Username/-Password/
        -SkipCertificateCheck supplied explicitly still wins over it. Enables piping
        between firewalls, e.g. Get-SfosIPHost -Session $fw1 | New-SfosIPHost -Session fw2.
 
        .PARAMETER Firewall
        Sophos Firewall hostname or IP address. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Port
        Management/API port number (typically 4444). If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Username
        Username for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Password
        Password for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER SkipCertificateCheck
        Skips SSL certificate validation for the API call.
 
        .OUTPUTS
        None. Throws an exception if the update fails or the threat is still present afterwards.
 
        .EXAMPLE
        Remove-SfosATPThreatException -Threat 'C2/Generic-A'
 
        .NOTES
        Minimum supported PowerShell version: 5.1
        Verified live as part of the ATP round trip - see the NOTES section of
        Remove-SfosATPHostException for the measured full-replace behaviour that applies
        identically to this list.
 
        .LINK
        https://docs.sophos.com/nsg/sophos-firewall/22.0/API/PROTECT/Active%20threat%20response/ATP/operations/SophosX-Opsthreatfeeds.html
 
        .LINK
        Get-SfosATPSettings
 
        .LINK
        Add-SfosATPThreatException
#>

function Remove-SfosATPThreatException {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Threat,

        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,

        [object]$Session
    )

    begin {
        $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters
    }

    process {
        $current = Get-SfosATPSettings -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -SkipCertificateCheck:$params.SkipCertificateCheck

        if (@($current.ThreatExceptionList) -notcontains $Threat) {
            throw "The ATP ThreatException '$Threat' was not found."
        }

        if (-not $PSCmdlet.ShouldProcess("ATP ThreatException '$Threat' on $($params.Firewall)", 'Remove')) {
            return
        }

        $newList = @($current.ThreatExceptionList | Where-Object -FilterScript { $_ -ne $Threat })

        Set-SfosATPSettings -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -SkipCertificateCheck:$params.SkipCertificateCheck `
            -ThreatException $newList `
            -Confirm:$false

        $after = Get-SfosATPSettings -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -SkipCertificateCheck:$params.SkipCertificateCheck

        if (@($after.ThreatExceptionList) -contains $Threat) {
            throw "Failed to remove ATP ThreatException '$Threat': the firewall reported success but the threat is still present."
        }
    }
}

#endregion

#region ThirdPartyFeed

<#
        .SYNOPSIS
        Retrieves ThirdPartyFeed objects from the Sophos Firewall.
 
        .DESCRIPTION
        Queries the Sophos Firewall XML API for ThirdPartyFeed objects (PROTECT > Active
        threat response > Third party threat feed). By default the cmdlet returns
        PowerShell-friendly objects. Use -AsXml to return the raw XML nodes.
 
        -NameLike is sent as the server-side filter key (confirmed live: 'like' criteria on
        Name works correctly, both for a matching substring and for a non-matching one, unlike
        several other entities in this API area where any filter is silently ignored or
        breaks the response) and is re-applied client-side together with every other filter,
        AND semantics, per the project's server-side filtering rule.
 
        .PARAMETER NameLike
        Filters by Name, substring match. Sent as the server-side filter key and re-applied
        client-side.
 
        .PARAMETER Session
        A session object returned by Connect-SfosFirewall, or the name of a session
        registered with Connect-SfosFirewall -Name. Overrides the stored default
        connection context; any of -Firewall/-Port/-Username/-Password/
        -SkipCertificateCheck supplied explicitly still wins over it. Enables piping
        between firewalls, e.g. Get-SfosIPHost -Session $fw1 | New-SfosIPHost -Session fw2.
 
        .PARAMETER Firewall
        Sophos Firewall hostname or IP address. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Port
        Management/API port number (typically 4444). If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Username
        Username for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Password
        Password for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER SkipCertificateCheck
        Skips SSL certificate validation for the API call.
 
        .PARAMETER AsXml
        Returns the raw XML nodes instead of PowerShell-friendly objects.
 
        .OUTPUTS
        PSCustomObject[] (default). System.Xml.XmlElement[] when -AsXml is specified.
 
        .EXAMPLE
        # List every configured third-party threat feed
        Get-SfosThirdPartyFeed
 
        .EXAMPLE
        # Find a feed by name
        Get-SfosThirdPartyFeed -NameLike 'Feed'
 
        .NOTES
        Minimum supported PowerShell version: 5.1
        Measured live: an empty firewall (and a filter with no matches) answers
        '<ThirdPartyFeed><Status>No. of records Zero.</Status></ThirdPartyFeed>' - a normal
        empty result, not an error; this cmdlet returns @() for it.
        The firewall assigns a server-generated <Id> (a GUID) to every feed on create; it is
        exposed here read-only and is not accepted as input by New-/Set-SfosThirdPartyFeed.
        <Position> is accepted on write (New-/Set-*) but is never returned by this Get - it
        does not appear as a property here; see New-SfosThirdPartyFeed's .NOTES for why
        Set-SfosThirdPartyFeed does not expose it at all.
        Username/Key are returned in plain text when the corresponding Authorization is
        configured; Password/Value (the secret half of basic/apiKey authorization) are
        returned firewall-hashed with a 'hashform' attribute, never in plain text - exposed
        here as PasswordHash/ValueHash for troubleshooting only, not usable as a credential.
 
        .LINK
        https://docs.sophos.com/nsg/sophos-firewall/22.0/API/PROTECT/Active%20threat%20response/thirdpartyfeeds/thirdpartyfeeds.html
 
        .LINK
        New-SfosThirdPartyFeed
#>

function Get-SfosThirdPartyFeed {
    [CmdletBinding()]
    param(
        [string]$NameLike,

        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,
        [object]$Session,

        [switch]$AsXml
    )

    $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters

    $filterXml = ''
    if ($NameLike) {
        $nameLikeEsc = ConvertTo-SfosXmlEscaped -Text $NameLike
        $filterXml = ('<Filter><key name="Name" criteria="like">{0}</key></Filter>' -f $nameLikeEsc)
    }

    $inner = @"
<Get>
  <ThirdPartyFeed>
    $filterXml
  </ThirdPartyFeed>
</Get>
"@


    try {
        $response = Invoke-SfosApi -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -InnerXml $inner -SkipCertificateCheck:$params.SkipCertificateCheck -ErrorAction Stop
    }
    catch {
        throw "Failed to retrieve ThirdPartyFeed objects: $($_.Exception.Message)"
    }

    $XmlResponse = [xml]$response.Content
    Assert-SfosApiReturnSuccess -Xml $XmlResponse -ObjectName 'ThirdPartyFeed' -Action 'get'

    $nodes = Select-Xml -Xml $XmlResponse -XPath '/Response/ThirdPartyFeed[Name]' | ForEach-Object -Process { $_.Node }

    $objects = foreach ($node in @($nodes)) {
        $passwordNode = $node.SelectSingleNode('Password')
        $valueNode = $node.SelectSingleNode('Value')

        [PSCustomObject]@{
            Id                        = [string]$node.Id
            Name                      = [string]$node.Name
            Description               = [string]$node.Description
            Action                    = [string]$node.Action
            IndicatorType             = [string]$node.IndicatorType
            ExternalURL               = [string]$node.ExternalURL
            Authorization             = [string]$node.Authorization
            Username                  = [string]$node.Username
            PasswordHash              = if ($passwordNode) { [string]$passwordNode.InnerText } else { '' }
            Key                       = [string]$node.Key
            ValueHash                 = if ($valueNode) { [string]$valueNode.InnerText } else { '' }
            AddTo                     = [string]$node.AddTo
            ValidateServerCertificate = [string]$node.ValidateServerCertificate
            PollingInterval           = [string]$node.PollingInterval
            Enabled                   = [string]$node.Enabled
        }
    }

    $objects = @($objects)
    if ($NameLike) {
        $objects = @($objects | Where-Object -FilterScript { $_.Name -like "*$NameLike*" })
    }

    if ($AsXml) {
        $keptNames = @($objects | ForEach-Object -Process { $_.Name })
        return @($nodes | Where-Object -FilterScript { $keptNames -contains $_.Name })
    }

    return $objects
}

<#
        .SYNOPSIS
        Creates a new ThirdPartyFeed object on the Sophos Firewall.
 
        .DESCRIPTION
        Creates a ThirdPartyFeed (PROTECT > Active threat response > Third party threat feed)
        using the Sophos Firewall XML API. Supports ShouldProcess; use -WhatIf to preview.
 
        .PARAMETER Name
        Name of the feed [doc]. Mandatory. Max 63 characters, letters/digits/underscore/hyphen
        only.
 
        .PARAMETER Description
        Free-text description [doc]. Optional, max 255 characters.
 
        .PARAMETER Action
        'block' or 'monitor' [doc]. Both values confirmed live. Default 'monitor' [doc].
 
        .PARAMETER Position
        'top' or 'bottom' [doc]. Both values were accepted without error live, but this field
        is never returned by Get-SfosThirdPartyFeed - see .NOTES. Default 'top' [doc].
 
        .PARAMETER IndicatorType
        'ip', 'domain' or 'url' [doc]. 'ip' and 'domain' confirmed live; 'url' is documented
        but was not exercised. Default 'ip' [doc].
 
        .PARAMETER ExternalURL
        URL the firewall polls for indicator data [doc]. Mandatory, max 1024 characters. A
        syntactically valid but unreachable URL under a documentation domain was accepted
        without the firewall attempting to reach it at creation time - see .NOTES.
 
        .PARAMETER Authorization
        'noAuthentication', 'basicAuthentication' or 'apiKey' [doc]. Default
        'noAuthentication' [doc]. Determines which of -FeedUsername/-FeedPassword or
        -ApiKeyName/-ApiKeyValue/-AddTo are required - see those parameters.
 
        .PARAMETER FeedUsername
        Username for -Authorization basicAuthentication [doc; wire element is <Username>].
        Named differently from the connection parameter -Username on purpose, for the same
        reason as -FeedPassword below - an entity field named -Username would bind to the
        connection parameter instead and silently leave the entity field unset, and
        Resolve-SfosParameters would misread the entity's basic-auth username as the API
        connection identity. Ignored for other -Authorization values.
 
        .PARAMETER FeedPassword
        Password for -Authorization basicAuthentication, as a SecureString [doc]. Named
        differently from the connection parameter -Password on purpose: an entity secret named
        -Password would bind to the connection parameter instead and silently leave the entity
        field unset.
        Required when -Authorization is basicAuthentication.
 
        .PARAMETER ApiKeyName
        The API key field/header name for -Authorization apiKey [doc; wire element is <Key>].
        Required when -Authorization is apiKey.
 
        .PARAMETER ApiKeyValue
        The API key secret value for -Authorization apiKey, as a SecureString [doc; wire
        element is <Value>]. Named to avoid a bare -Value/-Key ambiguity next to -ApiKeyName.
        Required when -Authorization is apiKey.
 
        .PARAMETER AddTo
        'header' or 'queryParam' for -Authorization apiKey [doc]. 'header' confirmed live;
        'queryParam' is documented but was not exercised. Required when -Authorization is
        apiKey.
 
        .PARAMETER ValidateServerCertificate
        '1' or '0' [doc table - the sample XML's true/false placeholders were also accepted on
        input, but the firewall always echoes the value back as '1'/'0' regardless of which
        form was sent, so the table wins here]. Mandatory
        [doc].
 
        .PARAMETER PollingInterval
        One of '5m','15m','30m','1h','6h','24h','7d','30d' [doc]. '1h' and '30m' confirmed
        live. Mandatory [doc].
 
        .PARAMETER Enabled
        '1' or '0' [doc table; sample XML's true/false was not tested for input, but the wire
        always returns '1'/'0'. Table wins here for the same reason as
        -ValidateServerCertificate]. Optional.
 
        .PARAMETER Session
        A session object returned by Connect-SfosFirewall, or the name of a session
        registered with Connect-SfosFirewall -Name. Overrides the stored default
        connection context; any of -Firewall/-Port/-Username/-Password/
        -SkipCertificateCheck supplied explicitly still wins over it. Enables piping
        between firewalls, e.g. Get-SfosIPHost -Session $fw1 | New-SfosIPHost -Session fw2.
 
        .PARAMETER Firewall
        Sophos Firewall hostname or IP address. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Port
        Management/API port number (typically 4444). If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Username
        Username for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Password
        Password for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER SkipCertificateCheck
        Skips SSL certificate validation for the API call.
 
        .OUTPUTS
        None. Throws an exception if creation fails.
 
        .EXAMPLE
        # Feed with no authentication
        New-SfosThirdPartyFeed -Name 'AbuseChIPFeed' -Action monitor -IndicatorType ip `
            -ExternalURL 'https://feeds.example.com/blocklist.txt' -Authorization noAuthentication `
            -ValidateServerCertificate 1 -PollingInterval 1h -Enabled 1
 
        .EXAMPLE
        # Feed with basic authentication
        $pw = ConvertTo-SecureString 'FeedSecret1!' -AsPlainText -Force
        New-SfosThirdPartyFeed -Name 'VendorIPFeed' -Action monitor -IndicatorType ip `
            -ExternalURL 'https://feeds.example.com/blocklist.txt' -Authorization basicAuthentication `
            -FeedUsername 'feedreader' -FeedPassword $pw `
            -ValidateServerCertificate 1 -PollingInterval 1h -Enabled 1
 
        .EXAMPLE
        # Feed with API key authentication
        $key = ConvertTo-SecureString 'MyApiKeyValue123' -AsPlainText -Force
        New-SfosThirdPartyFeed -Name 'PartnerIPFeed' -Action monitor -IndicatorType ip `
            -ExternalURL 'https://feeds.example.com/blocklist.txt' -Authorization apiKey `
            -ApiKeyName 'X-Api-Key' -ApiKeyValue $key -AddTo header `
            -ValidateServerCertificate 1 -PollingInterval 1h -Enabled 1
 
        .NOTES
        Minimum supported PowerShell version: 5.1
        Verified live: created with -Authorization noAuthentication (code 201, confirmed with
        a follow-up Get, all fields matched including the server-assigned Id), then updated in
        place through -Authorization basicAuthentication and apiKey to exercise those code
        paths - see Set-SfosThirdPartyFeed's .NOTES, which carries the security-relevant
        findings for the shared secret handling. The example -ExternalURL used throughout
        (feeds.example.com, syntactically valid but not a real host) was accepted by the
        firewall without any reachability check at write time - unlike WebFilterCategory,
        no 217/222 warning code was seen, just a plain 201 "Configuration applied
        successfully.".
        <Id> is server-assigned and cannot be set by the caller - not exposed as a parameter.
        <Position> is accepted here (create time) but is never returned by
        Get-SfosThirdPartyFeed: since a field a Get-* does not
        expose is impossible to preserve on update, Set-SfosThirdPartyFeed has no
        -Position parameter at all, matching the FileType/-Template precedent. Only New-* can
        set it, and only once, at creation.
        Creating a second object with a Name already in use answers a field-precise 409
        naming /ThirdPartyFeed/Name [measured] - outside the documented 200-216/500-599 status
        table, so it throws by default rather than being waved through, consistent with this
        project's fail-closed handling of undocumented codes.
 
        .LINK
        https://docs.sophos.com/nsg/sophos-firewall/22.0/API/PROTECT/Active%20threat%20response/thirdpartyfeeds/operations/AddThird-partythreatfeed%26EditThird-partythreatfeed.html
 
        .LINK
        Get-SfosThirdPartyFeed
#>

function New-SfosThirdPartyFeed {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [ValidateLength(1, 63)]
        [ValidatePattern('^[A-Za-z0-9_-]+$')]
        [string]$Name,

        [ValidateLength(0, 255)]
        [string]$Description = '',

        [ValidateSet('block', 'monitor')]
        [string]$Action = 'monitor',

        [ValidateSet('top', 'bottom')]
        [string]$Position = 'top',

        [ValidateSet('ip', 'domain', 'url')]
        [string]$IndicatorType = 'ip',

        [Parameter(Mandatory)]
        [ValidateLength(1, 1024)]
        [string]$ExternalURL,

        [ValidateSet('noAuthentication', 'basicAuthentication', 'apiKey')]
        [string]$Authorization = 'noAuthentication',

        [string]$FeedUsername,

        [SecureString]$FeedPassword,

        [string]$ApiKeyName,

        [SecureString]$ApiKeyValue,

        [ValidateSet('header', 'queryParam')]
        [string]$AddTo,

        [Parameter(Mandatory)]
        [ValidateSet('1', '0')]
        [string]$ValidateServerCertificate,

        [Parameter(Mandatory)]
        [ValidateSet('5m', '15m', '30m', '1h', '6h', '24h', '7d', '30d')]
        [string]$PollingInterval,

        [ValidateSet('1', '0')]
        [string]$Enabled = '1',

        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,

        [object]$Session
    )

    $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters

    if ($Authorization -eq 'basicAuthentication' -and -not $FeedPassword) {
        throw "ThirdPartyFeed '$Name': -Authorization basicAuthentication requires -FeedPassword."
    }
    if ($Authorization -eq 'apiKey' -and (-not $ApiKeyName -or -not $ApiKeyValue -or -not $AddTo)) {
        throw "ThirdPartyFeed '$Name': -Authorization apiKey requires -ApiKeyName, -ApiKeyValue and -AddTo."
    }

    if (-not $PSCmdlet.ShouldProcess("ThirdPartyFeed '$Name' on $($params.Firewall)", 'Create')) {
        return
    }

    $nameEsc = ConvertTo-SfosXmlEscaped -Text $Name
    $descriptionEsc = ConvertTo-SfosXmlEscaped -Text $Description
    $externalUrlEsc = ConvertTo-SfosXmlEscaped -Text $ExternalURL

    $authXml = ''
    if ($Authorization -eq 'basicAuthentication') {
        $usernameEsc = ConvertTo-SfosXmlEscaped -Text $FeedUsername
        $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($FeedPassword)
        try {
            $plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
        }
        finally {
            [Runtime.InteropServices.Marshal]::FreeBSTR($bstr)
        }
        $passwordEsc = ConvertTo-SfosXmlEscaped -Text $plainPassword
        $authXml = "<Username>$usernameEsc</Username><Password>$passwordEsc</Password>"
    }
    elseif ($Authorization -eq 'apiKey') {
        $keyEsc = ConvertTo-SfosXmlEscaped -Text $ApiKeyName
        $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($ApiKeyValue)
        try {
            $plainValue = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
        }
        finally {
            [Runtime.InteropServices.Marshal]::FreeBSTR($bstr)
        }
        $valueEsc = ConvertTo-SfosXmlEscaped -Text $plainValue
        $authXml = "<Key>$keyEsc</Key><Value>$valueEsc</Value><AddTo>$AddTo</AddTo>"
    }

    $inner = @"
<Set operation="add">
  <ThirdPartyFeed>
    <Name>$nameEsc</Name>
    <Description>$descriptionEsc</Description>
    <Action>$Action</Action>
    <Position>$Position</Position>
    <IndicatorType>$IndicatorType</IndicatorType>
    <ExternalURL>$externalUrlEsc</ExternalURL>
    <Authorization>$Authorization</Authorization>
    $authXml
    <ValidateServerCertificate>$ValidateServerCertificate</ValidateServerCertificate>
    <PollingInterval>$PollingInterval</PollingInterval>
    <Enabled>$Enabled</Enabled>
  </ThirdPartyFeed>
</Set>
"@


    try {
        $response = Invoke-SfosApi -Firewall $params.Firewall `
            -Port $params.Port `
            -Username $params.Username `
            -Password $params.Password `
            -InnerXml $inner -SkipCertificateCheck:$params.SkipCertificateCheck -ErrorAction Stop
    }
    catch {
        throw "Failed to create ThirdPartyFeed object '$Name': $($_.Exception.Message)"
    }

    $XmlResponse = [xml]$response.Content
    Assert-SfosApiReturnSuccess -Xml $XmlResponse -ObjectName 'ThirdPartyFeed' -Action 'create' -Target $Name
}

<#
        .SYNOPSIS
        Updates an existing ThirdPartyFeed object on the Sophos Firewall.
 
        .DESCRIPTION
        Updates a ThirdPartyFeed using the Sophos Firewall XML API. Reads the current object
        first and resends every field, overriding only what the caller explicitly passed
        (read-modify-write - SFOS replaces the whole entity on update). Supports ShouldProcess;
        use -WhatIf to preview.
 
        The Password/Key secret fields are the one measured exception to full replace on this
        entity - see .NOTES. This cmdlet never resends the hashed value Get-SfosThirdPartyFeed
        returns; when the caller does not supply a new secret and the authorization type is
        unchanged, the corresponding element is omitted entirely, which was measured to leave
        the stored secret untouched.
 
        .PARAMETER Name
        Name of the target feed. Mandatory; accepts pipeline input by property name.
 
        .PARAMETER Description
        Free-text description. If omitted, the existing value is kept.
 
        .PARAMETER Action
        'block' or 'monitor'. If omitted, the existing value is kept.
 
        .PARAMETER IndicatorType
        'ip', 'domain' or 'url'. If omitted, the existing value is kept.
 
        .PARAMETER ExternalURL
        URL the firewall polls for indicator data. If omitted, the existing value is kept.
 
        .PARAMETER Authorization
        'noAuthentication', 'basicAuthentication' or 'apiKey'. If omitted, the existing value
        is kept. Switching to basicAuthentication or apiKey without a previous value of that
        type requires the matching secret parameter - see -FeedPassword/-ApiKeyValue.
 
        .PARAMETER FeedUsername
        Username for -Authorization basicAuthentication [wire element <Username>]. Named
        differently from the connection parameter -Username on purpose - see
        New-SfosThirdPartyFeed's -FeedUsername for why. If omitted, the existing value is
        kept.
 
        .PARAMETER FeedPassword
        New password for -Authorization basicAuthentication, as a SecureString. If omitted and
        the feed is already (or remains) basicAuthentication, the existing password is left
        untouched on the firewall - see .NOTES. Required when switching from a different
        -Authorization value to basicAuthentication in the same call.
 
        .PARAMETER ApiKeyName
        The API key field/header name for -Authorization apiKey. If omitted, the existing
        value is kept.
 
        .PARAMETER ApiKeyValue
        New API key secret value for -Authorization apiKey, as a SecureString. If omitted and
        the feed is already (or remains) apiKey, the existing value is left untouched on the
        firewall - see .NOTES. Required when switching from a different -Authorization value
        to apiKey in the same call.
 
        .PARAMETER AddTo
        'header' or 'queryParam' for -Authorization apiKey. If omitted, the existing value is
        kept.
 
        .PARAMETER ValidateServerCertificate
        '1' or '0'. If omitted, the existing value is kept.
 
        .PARAMETER PollingInterval
        One of '5m','15m','30m','1h','6h','24h','7d','30d'. If omitted, the existing value is
        kept.
 
        .PARAMETER Enabled
        '1' or '0'. If omitted, the existing value is kept.
 
        .PARAMETER Session
        A session object returned by Connect-SfosFirewall, or the name of a session
        registered with Connect-SfosFirewall -Name. Overrides the stored default
        connection context; any of -Firewall/-Port/-Username/-Password/
        -SkipCertificateCheck supplied explicitly still wins over it. Enables piping
        between firewalls, e.g. Get-SfosIPHost -Session $fw1 | New-SfosIPHost -Session fw2.
 
        .PARAMETER Firewall
        Sophos Firewall hostname or IP address. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Port
        Management/API port number (typically 4444). If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Username
        Username for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Password
        Password for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER SkipCertificateCheck
        Skips SSL certificate validation for the API call.
 
        .OUTPUTS
        None. Throws an exception if the update fails.
 
        .EXAMPLE
        # Disable a feed, leaving every other field untouched (including any stored secret)
        Set-SfosThirdPartyFeed -Name 'AbuseChIPFeed' -Enabled 0
 
        .NOTES
        Minimum supported PowerShell version: 5.1
        Two measured findings specific to this entity's secret fields, both reproduced twice
        against the lab appliance:
 
        1. Resending the Password (or Value) element exactly as Get-SfosThirdPartyFeed returns
           it - hashed text plus its 'hashform' attribute, the pattern that works for
           Set-SfosSSLBookmark - makes SFOS answer HTTP 200 with a response that contains no
           <ThirdPartyFeed> element and no <Status> at all, and the update is silently
           dropped: a Description change sent in the same request was confirmed absent on a
           follow-up Get. This is the "missing status element is not success" trap, and Core's
           generic status check cannot catch it, because there is no ThirdPartyFeed subtree in
           the response to look inside at all.
        2. Omitting the Password (or Value) element entirely, while Authorization stays the
           same authenticated type, answers a normal 200 AND the previously stored password
           hash comes back completely unchanged on the next Get. This is the opposite of the
           project's usual full-replace warning for Set-* - here NOT sending the field is what
           preserves it, and sending the read-back hash is what breaks the whole request. This
           cmdlet relies on that: it never resends PasswordHash/ValueHash, only a caller-
           supplied new SecureString or nothing at all.
 
        Switching Authorization to noAuthentication (with every auth sub-field omitted) was
        confirmed to properly clear Username/Password/Key/Value/AddTo - normal full-replace
        semantics apply there, only the "keep the existing secret while remaining in the same
        authenticated mode" case is special.
 
        Verified live: full round trip through noAuthentication -> basicAuthentication (with
        -FeedPassword) -> apiKey (with -ApiKeyValue) -> back to noAuthentication, each
        transition confirmed correct on a follow-up Get, before the test object was removed.
 
        .LINK
        https://docs.sophos.com/nsg/sophos-firewall/22.0/API/PROTECT/Active%20threat%20response/thirdpartyfeeds/operations/AddThird-partythreatfeed%26EditThird-partythreatfeed.html
 
        .LINK
        Get-SfosThirdPartyFeed
#>

function Set-SfosThirdPartyFeed {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Name,

        [string]$Description,

        [ValidateSet('block', 'monitor')]
        [string]$Action,

        [ValidateSet('ip', 'domain', 'url')]
        [string]$IndicatorType,

        [string]$ExternalURL,

        [ValidateSet('noAuthentication', 'basicAuthentication', 'apiKey')]
        [string]$Authorization,

        [string]$FeedUsername,

        [SecureString]$FeedPassword,

        [string]$ApiKeyName,

        [SecureString]$ApiKeyValue,

        [ValidateSet('header', 'queryParam')]
        [string]$AddTo,

        [ValidateSet('1', '0')]
        [string]$ValidateServerCertificate,

        [ValidateSet('5m', '15m', '30m', '1h', '6h', '24h', '7d', '30d')]
        [string]$PollingInterval,

        [ValidateSet('1', '0')]
        [string]$Enabled,

        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,

        [object]$Session
    )

    begin {
        $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters
    }

    process {
        $bp = $PSBoundParameters

        $existing = @(Get-SfosThirdPartyFeed -Firewall $params.Firewall `
                -Port $params.Port `
                -Username $params.Username `
                -Password $params.Password `
                -NameLike $Name `
                -SkipCertificateCheck:$params.SkipCertificateCheck |
                Where-Object -FilterScript { $_.Name -eq $Name })

        if ($existing.Count -eq 0) {
            throw "The ThirdPartyFeed object '$Name' was not found."
        }
        $current = $existing[0]

        $targetDescription = if ($bp.ContainsKey('Description')) { $Description } else { [string]$current.Description }
        $targetAction = if ($bp.ContainsKey('Action')) { $Action } else { $current.Action }
        $targetIndicatorType = if ($bp.ContainsKey('IndicatorType')) { $IndicatorType } else { $current.IndicatorType }
        $targetExternalURL = if ($bp.ContainsKey('ExternalURL')) { $ExternalURL } else { $current.ExternalURL }
        $targetAuthorization = if ($bp.ContainsKey('Authorization')) { $Authorization } else { $current.Authorization }
        $targetValidateServerCertificate = if ($bp.ContainsKey('ValidateServerCertificate')) { $ValidateServerCertificate } else { $current.ValidateServerCertificate }
        $targetPollingInterval = if ($bp.ContainsKey('PollingInterval')) { $PollingInterval } else { $current.PollingInterval }
        $targetEnabled = if ($bp.ContainsKey('Enabled')) { $Enabled } else { $current.Enabled }

        $authXml = ''
        if ($targetAuthorization -eq 'basicAuthentication') {
            $targetUsername = if ($bp.ContainsKey('FeedUsername')) { $FeedUsername } else { [string]$current.Username }
            $usernameEsc = ConvertTo-SfosXmlEscaped -Text $targetUsername

            if ($bp.ContainsKey('FeedPassword')) {
                $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($FeedPassword)
                try {
                    $plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
                }
                finally {
                    [Runtime.InteropServices.Marshal]::FreeBSTR($bstr)
                }
                $passwordEsc = ConvertTo-SfosXmlEscaped -Text $plainPassword
                $authXml = "<Username>$usernameEsc</Username><Password>$passwordEsc</Password>"
            }
            elseif ($current.Authorization -eq 'basicAuthentication') {
                # Measured: omitting Password here (while staying basicAuthentication)
                # preserves the stored secret - see .NOTES. Resending PasswordHash would
                # silently drop the whole update instead.
                $authXml = "<Username>$usernameEsc</Username>"
            }
            else {
                throw "ThirdPartyFeed '$Name': switching -Authorization to basicAuthentication requires -FeedPassword."
            }
        }
        elseif ($targetAuthorization -eq 'apiKey') {
            $targetApiKeyName = if ($bp.ContainsKey('ApiKeyName')) { $ApiKeyName } else { [string]$current.Key }
            $keyEsc = ConvertTo-SfosXmlEscaped -Text $targetApiKeyName
            $targetAddTo = if ($bp.ContainsKey('AddTo')) { $AddTo } else { $current.AddTo }

            if ($bp.ContainsKey('ApiKeyValue')) {
                $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($ApiKeyValue)
                try {
                    $plainValue = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
                }
                finally {
                    [Runtime.InteropServices.Marshal]::FreeBSTR($bstr)
                }
                $valueEsc = ConvertTo-SfosXmlEscaped -Text $plainValue
                $authXml = "<Key>$keyEsc</Key><Value>$valueEsc</Value><AddTo>$targetAddTo</AddTo>"
            }
            elseif ($current.Authorization -eq 'apiKey') {
                # Same measured preserve-on-omit behaviour as Password above.
                $authXml = "<Key>$keyEsc</Key><AddTo>$targetAddTo</AddTo>"
            }
            else {
                throw "ThirdPartyFeed '$Name': switching -Authorization to apiKey requires -ApiKeyName, -ApiKeyValue and -AddTo."
            }
        }

        if (-not $PSCmdlet.ShouldProcess("ThirdPartyFeed '$Name' on $($params.Firewall)", 'Update')) {
            return
        }

        $nameEsc = ConvertTo-SfosXmlEscaped -Text $Name
        $descriptionEsc = ConvertTo-SfosXmlEscaped -Text $targetDescription
        $externalUrlEsc = ConvertTo-SfosXmlEscaped -Text $targetExternalURL

        $inner = @"
<Set operation="update">
  <ThirdPartyFeed>
    <Name>$nameEsc</Name>
    <Description>$descriptionEsc</Description>
    <Action>$targetAction</Action>
    <IndicatorType>$targetIndicatorType</IndicatorType>
    <ExternalURL>$externalUrlEsc</ExternalURL>
    <Authorization>$targetAuthorization</Authorization>
    $authXml
    <ValidateServerCertificate>$targetValidateServerCertificate</ValidateServerCertificate>
    <PollingInterval>$targetPollingInterval</PollingInterval>
    <Enabled>$targetEnabled</Enabled>
  </ThirdPartyFeed>
</Set>
"@


        try {
            $response = Invoke-SfosApi -Firewall $params.Firewall `
                -Port $params.Port `
                -Username $params.Username `
                -Password $params.Password `
                -InnerXml $inner -SkipCertificateCheck:$params.SkipCertificateCheck -ErrorAction Stop
        }
        catch {
            throw "Failed to update ThirdPartyFeed object '$Name': $($_.Exception.Message)"
        }

        $XmlResponse = [xml]$response.Content
        Assert-SfosApiReturnSuccess -Xml $XmlResponse -ObjectName 'ThirdPartyFeed' -Action 'update' -Target $Name
    }
}

<#
        .SYNOPSIS
        Removes a ThirdPartyFeed object from the Sophos Firewall.
 
        .DESCRIPTION
        Removes a ThirdPartyFeed using the Sophos Firewall XML API. Reads the object first and
        throws a clear "not found" error if it does not exist. The firewall's own delete
        response for this entity cannot be trusted at face value - see .NOTES - so this
        cmdlet confirms the outcome with a follow-up Get instead of relying on the returned
        status code. Supports ShouldProcess; use -WhatIf to preview.
 
        .PARAMETER Name
        Name of the feed to remove. Mandatory; accepts pipeline input by property name.
 
        .PARAMETER Session
        A session object returned by Connect-SfosFirewall, or the name of a session
        registered with Connect-SfosFirewall -Name. Overrides the stored default
        connection context; any of -Firewall/-Port/-Username/-Password/
        -SkipCertificateCheck supplied explicitly still wins over it. Enables piping
        between firewalls, e.g. Get-SfosIPHost -Session $fw1 | New-SfosIPHost -Session fw2.
 
        .PARAMETER Firewall
        Sophos Firewall hostname or IP address. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Port
        Management/API port number (typically 4444). If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Username
        Username for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER Password
        Password for API authentication. If omitted, the cmdlet attempts to use the stored connection context.
 
        .PARAMETER SkipCertificateCheck
        Skips SSL certificate validation for the API call.
 
        .OUTPUTS
        None. Throws an exception if the removal fails or cannot be confirmed.
 
        .EXAMPLE
        Remove-SfosThirdPartyFeed -Name 'AbuseChIPFeed'
 
        .NOTES
        Minimum supported PowerShell version: 5.1
        Measured live and reproduced twice: Remove on this entity always answers code 500
        "Deleted some configurations. Couldn't delete all." - for a genuinely nonexistent
        object AND for a real object that the same call actually deletes cleanly. The message
        text is identical in both cases, so the status code and message cannot distinguish
        success from failure here at all (a stricter defect than the Code-528-on-nonexistent
        pattern documented for other Remove-Sfos* cmdlets in this project, where at least the
        object legitimately not existing is the trigger). This cmdlet therefore ignores that
        status entirely (beyond checking the login itself did not fail) and determines the
        real outcome from a follow-up Get: both the "object never existed" and "object deleted
        successfully" paths were confirmed this way, twice.
 
        .LINK
        https://docs.sophos.com/nsg/sophos-firewall/22.0/API/PROTECT/Active%20threat%20response/thirdpartyfeeds/operations/Delete%20Third-party%20threat%20feed.html
 
        .LINK
        Get-SfosThirdPartyFeed
#>

function Remove-SfosThirdPartyFeed {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Name,

        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,

        [object]$Session
    )

    begin {
        $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters
    }

    process {
        $existing = @(Get-SfosThirdPartyFeed -Firewall $params.Firewall `
                -Port $params.Port `
                -Username $params.Username `
                -Password $params.Password `
                -NameLike $Name `
                -SkipCertificateCheck:$params.SkipCertificateCheck |
                Where-Object -FilterScript { $_.Name -eq $Name })

        if ($existing.Count -eq 0) {
            throw "The ThirdPartyFeed object '$Name' was not found."
        }

        if (-not $PSCmdlet.ShouldProcess("ThirdPartyFeed '$Name' on $($params.Firewall)", 'Remove')) {
            return
        }

        $nameEsc = ConvertTo-SfosXmlEscaped -Text $Name

        $inner = @"
<Remove>
  <ThirdPartyFeed>
    <Name>$nameEsc</Name>
  </ThirdPartyFeed>
</Remove>
"@


        try {
            $response = Invoke-SfosApi -Firewall $params.Firewall `
                -Port $params.Port `
                -Username $params.Username `
                -Password $params.Password `
                -InnerXml $inner -SkipCertificateCheck:$params.SkipCertificateCheck -ErrorAction Stop
        }
        catch {
            throw "Failed to remove ThirdPartyFeed object '$Name': $($_.Exception.Message)"
        }

        # See .NOTES: the status code/message this entity returns on Remove cannot
        # distinguish success from failure, so only the login is checked here and the real
        # outcome is confirmed below with a follow-up Get.
        $XmlResponse = [xml]$response.Content
        $loginNode = $XmlResponse.SelectSingleNode('/Response/Login/status')
        if ($loginNode -and [string]$loginNode.InnerText -notmatch 'Success') {
            throw "Sophos API login failed while trying to remove ThirdPartyFeed object '$Name'. $([string]$loginNode.InnerText)"
        }

        $stillThere = @(Get-SfosThirdPartyFeed -Firewall $params.Firewall `
                -Port $params.Port `
                -Username $params.Username `
                -Password $params.Password `
                -NameLike $Name `
                -SkipCertificateCheck:$params.SkipCertificateCheck |
                Where-Object -FilterScript { $_.Name -eq $Name })

        if ($stillThere.Count -gt 0) {
            throw "Failed to remove ThirdPartyFeed object '$Name': the firewall's delete response cannot be trusted for this entity (see .NOTES) and the object is still present."
        }
    }
}

#endregion