SophosFirewall.SophosCentral.psm1

#requires -Version 5.1
#requires -Modules @{ ModuleName = 'SophosFirewall.Core'; ModuleVersion = '1.4.1' }

<#
.SYNOPSIS
    Manages Sophos Central cloud management and registration on a Sophos Firewall.
 
.DESCRIPTION
    Functions for the SYSTEM > Sophos Central area of the Sophos Firewall: the cloud central
    management switches - centralized reporting, management from Sophos Central, and
    configuration backup - that sit under EnableCloudCentralManagement in the XML API, and the
    Synchronized Security settings - Security Heartbeat and Synchronized Application Control -
    read and written through the web admin interface's own JSON REST endpoint.
 
    Registering a firewall with a Sophos Central tenant is deliberately not part of this
    module. The API offers exactly one way to do it, and that way sends the account name and
    password of a Sophos Central administrator in the request body. A tenant secured with a
    passkey, or with any other passwordless sign-in, cannot supply those, and the one-time
    password the web admin console accepts instead has no API equivalent. Register the
    firewall through the web admin console, then use this module for the switches.
 
    Total Functions: 4 - see README.md for the full cmdlet table.
 
    Connect once with Connect-SfosFirewall, then call the cmdlets in this module without
    repeating the connection parameters.
 
.EXAMPLE
    Connect-SfosFirewall -Firewall '192.0.2.1' -Credential (Get-Credential) -SkipCertificateCheck
    Get-SfosCentralManagement
 
    Connects to the firewall and reads the current cloud central management settings.
 
.EXAMPLE
    Set-SfosCentralManagement -UseCentralReporting Disable -Confirm:$false
    Get-SfosCentralManagement
 
    Switches Sophos Central reporting off, leaving management, backup and join method
    untouched, then confirms the new state.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
 
.LINK
    Connect-SfosFirewall
#>



#region CentralManagement

<#
.SYNOPSIS
    Retrieves the Sophos Central cloud management settings from a Sophos Firewall.
 
.DESCRIPTION
    Reads the four settings under EnableCloudCentralManagement: whether the firewall is
    managed from Sophos Central, uses Sophos Central for centralized reporting, sends
    configuration backups there, and how it joined the tenant. Read-only; needs a
    connection from Connect-SfosFirewall. Despite the wire element's name this is a
    settings object, not a command that triggers anything.
 
.PARAMETER Firewall
    Optional. Host name or IP address of the firewall. If omitted, the value from the
    current connection is used.
 
.PARAMETER Port
    Optional. TCP port of the management API, usually 4444. If omitted, the value from
    the current connection is used.
 
.PARAMETER Username
    Optional. User name for the API login. The account needs read permission for the
    Sophos Central settings. If omitted, the value from the current connection is used.
 
.PARAMETER Password
    Optional. Password for the API login, as a SecureString. If omitted, the value from
    the current connection is used.
 
.PARAMETER SkipCertificateCheck
    Optional. Accepts the firewall certificate without validating it. Use this only for
    appliances that still present a self-signed certificate. If omitted, the certificate
    is validated.
 
.PARAMETER Session
    Optional. A session object from Connect-SfosFirewall, or the name of a session that
    was registered with Connect-SfosFirewall -Name. Use it to address a specific firewall
    when you work with more than one at a time. Any connection parameter you pass
    explicitly still takes precedence. If omitted, the stored default connection is used.
 
.PARAMETER AsXml
    Optional. Returns the raw XML element sent by the firewall instead of a PowerShell
    object.
 
.INPUTS
    None. This cmdlet does not accept pipeline input.
 
.OUTPUTS
    System.Management.Automation.PSCustomObject. One object with the properties FWBackup,
    JoinMethod, UseCentralReporting and CMStatus. UseCentralReporting and CMStatus normally
    read Enable or Disable, but can also read the undocumented value WaitingForApproval while
    a service that was just switched on is waiting for a super admin to confirm it in the
    Sophos Central console. Returns System.Xml.XmlElement when -AsXml is used.
 
.NOTES
    Does not reveal whether the firewall is actually registered with Sophos Central - these
    switches read independently of the registration state, so CMStatus can read Enable
    without a registration ever having been confirmed. Check the Sophos Central status in
    the web admin console instead.
 
.EXAMPLE
    Get-SfosCentralManagement
 
    Returns the current Sophos Central management settings of the firewall of the current
    connection.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
 
.LINK
    Set-SfosCentralManagement
#>

function Get-SfosCentralManagement {
    [CmdletBinding()]
    param(
        # Connection parameters (optional - use stored context if not provided)
        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,
        [object]$Session,

        # Output parameters
        [switch]$AsXml
    )

    $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters

    $inner = '<Get><EnableCloudCentralManagement></EnableCloudCentralManagement></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 "Error retrieving CentralManagement: $($_.Exception.Message)"
    }

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

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

    if ($AsXml) {
        return $node
    }

    return [PSCustomObject]@{
        FWBackup            = [string]$node.FWBackup
        JoinMethod          = [string]$node.JoinMethod
        UseCentralReporting = [string]$node.UseCentralReporting
        CMStatus            = [string]$node.CMStatus
    }
}

<#
.SYNOPSIS
    Updates the Sophos Central cloud management settings on a Sophos Firewall.
 
.DESCRIPTION
    Updates the four EnableCloudCentralManagement switches. Reads the current settings
    first and resends every field, so anything not passed keeps its value. Needs a
    connection from Connect-SfosFirewall and an account permitted to change Sophos
    Central settings.
 
.PARAMETER FWBackup
    Optional. Whether configuration backups are sent to Sophos Central: BackupEnable or
    BackupDisable. Corresponds to "Send configuration backup to Sophos Central" in the web
    admin console. Requires CMStatus to resolve to Enable - see the notes. If omitted,
    the current value is kept.
 
.PARAMETER JoinMethod
    Optional. How the firewall joined Sophos Central: Manual or ZeroTouch. This switch has
    no equivalent in the web admin console; it reflects how the join happened rather than
    being a setting an administrator toggles directly. If omitted, the current value is
    kept.
 
.PARAMETER UseCentralReporting
    Optional. Whether centralized reporting in Sophos Central is on: Enable or Disable.
    Corresponds to "Use Sophos Central reporting" in the web admin console. Switching this to
    Enable is accepted by the firewall but not completed by this cmdlet alone - see the
    notes. If omitted, the current value is kept.
 
.PARAMETER CMStatus
    Optional. Whether the firewall is managed from Sophos Central: Enable or Disable.
    Corresponds to "Use Sophos Central management" in the web admin console. Switching this
    to Enable is accepted by the firewall but not completed by this cmdlet alone - see the
    notes. If omitted, the current value is kept.
 
.PARAMETER Firewall
    Optional. Host name or IP address of the firewall. If omitted, the value from the
    current connection is used.
 
.PARAMETER Port
    Optional. TCP port of the management API, usually 4444. If omitted, the value from
    the current connection is used.
 
.PARAMETER Username
    Optional. User name for the API login. The account needs permission to change Sophos
    Central settings. If omitted, the value from the current connection is used.
 
.PARAMETER Password
    Optional. Password for the API login, as a SecureString. If omitted, the value from
    the current connection is used.
 
.PARAMETER SkipCertificateCheck
    Optional. Accepts the firewall certificate without validating it. Use this only for
    appliances that still present a self-signed certificate. If omitted, the certificate
    is validated.
 
.PARAMETER Session
    Optional. A session object from Connect-SfosFirewall, or the name of a session that
    was registered with Connect-SfosFirewall -Name. Use it to address a specific firewall
    when you work with more than one at a time. Any connection parameter you pass
    explicitly still takes precedence. If omitted, the stored default connection is used.
 
.INPUTS
    None. This cmdlet does not accept pipeline input.
 
.OUTPUTS
    None. The cmdlet writes no output. It throws if the firewall rejects the update, and
    also if the firewall reports success but a requested change to UseCentralReporting or
    CMStatus was not applied - see the notes.
 
.NOTES
    Switching a service off takes effect immediately. Switching one back on is reported
    as success but stays pending until a super admin runs Accept services in the Sophos
    Central console - the cmdlet reads the settings back and throws when a field still
    shows its old value, and warns on the undocumented value WaitingForApproval.
 
    FWBackup requires CMStatus Enable; the cmdlet refuses the combination the web admin
    console cannot produce either.
 
    Registration is out of scope - this cmdlet neither registers nor unregisters the
    firewall, and CMStatus Disable does not end an existing registration.
 
.EXAMPLE
    Set-SfosCentralManagement -CMStatus Disable -WhatIf
 
    Shows what the call would change without sending it to the firewall.
 
.EXAMPLE
    Set-SfosCentralManagement -UseCentralReporting Disable -Confirm:$false
 
    Switches only centralized reporting off, leaving management, backup and join method
    untouched, without asking for confirmation. Use this form only in scripts where the
    value has already been reviewed.
 
.EXAMPLE
    Set-SfosCentralManagement -FWBackup BackupEnable -CMStatus Disable -Confirm:$false
 
    Throws before sending anything to the firewall, because FWBackup cannot be BackupEnable
    while CMStatus is Disable.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
 
.LINK
    Get-SfosCentralManagement
#>

function Set-SfosCentralManagement {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [ValidateSet('BackupEnable', 'BackupDisable')]
        [string]$FWBackup,

        [ValidateSet('Manual', 'ZeroTouch')]
        [string]$JoinMethod,

        [ValidateSet('Enable', 'Disable')]
        [string]$UseCentralReporting,

        [ValidateSet('Enable', 'Disable')]
        [string]$CMStatus,

        # Connection parameters (optional - use stored context if not provided)
        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,

        [object]$Session
    )

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

    # Cheap short-circuit: if the caller passed both FWBackup and CMStatus explicitly, the
    # conflict is already fully known and is reported before the firewall is contacted at
    # all - not just before anything is written.
    if ($bp.ContainsKey('FWBackup') -and $bp.ContainsKey('CMStatus') -and
        $FWBackup -eq 'BackupEnable' -and $CMStatus -eq 'Disable') {
        throw "Cannot update CentralManagement: FWBackup cannot be 'BackupEnable' together with CMStatus 'Disable' - configuration backup to Sophos Central depends on Sophos Central management being on, matching the web admin console. Set CMStatus to 'Enable' as well, or leave FWBackup at 'BackupDisable'."
    }

    $existing = Get-SfosCentralManagement -Firewall $params.Firewall `
        -Port $params.Port `
        -Username $params.Username `
        -Password $params.Password `
        -SkipCertificateCheck:$params.SkipCertificateCheck

    $targetFWBackup = if ($bp.ContainsKey('FWBackup')) { $FWBackup } else { $existing.FWBackup }
    $targetJoinMethod = if ($bp.ContainsKey('JoinMethod')) { $JoinMethod } else { $existing.JoinMethod }
    $targetUseCentralReporting = if ($bp.ContainsKey('UseCentralReporting')) { $UseCentralReporting } else { $existing.UseCentralReporting }
    $targetCMStatus = if ($bp.ContainsKey('CMStatus')) { $CMStatus } else { $existing.CMStatus }

    if ($targetFWBackup -eq 'BackupEnable' -and $targetCMStatus -eq 'Disable') {
        throw "Cannot update CentralManagement: FWBackup cannot be 'BackupEnable' while CMStatus resolves to 'Disable' (the current value on the firewall, since it was not passed) - configuration backup to Sophos Central depends on Sophos Central management being on, matching the web admin console. Set CMStatus to 'Enable' as well, or leave FWBackup at 'BackupDisable'."
    }

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

    $inner = @"
<Set operation="update">
  <EnableCloudCentralManagement>
    <FWBackup>$(ConvertTo-SfosXmlEscaped -Text $targetFWBackup)</FWBackup>
    <JoinMethod>$(ConvertTo-SfosXmlEscaped -Text $targetJoinMethod)</JoinMethod>
    <UseCentralReporting>$(ConvertTo-SfosXmlEscaped -Text $targetUseCentralReporting)</UseCentralReporting>
    <CMStatus>$(ConvertTo-SfosXmlEscaped -Text $targetCMStatus)</CMStatus>
  </EnableCloudCentralManagement>
</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 "Error updating CentralManagement: $($_.Exception.Message)"
    }

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

    # The firewall answers 200 for a write it does not apply. Turning a Sophos Central
    # service off works; turning one back on is accepted, reported successful and silently
    # ignored, because a service has to be accepted in the Sophos Central console before it
    # runs. A cmdlet whose name promises the change therefore reads the object back and
    # throws instead of leaving the caller with a success that never happened.
    $applied = Get-SfosCentralManagement -Firewall $params.Firewall `
        -Port $params.Port `
        -Username $params.Username `
        -Password $params.Password `
        -SkipCertificateCheck:$params.SkipCertificateCheck

    $mismatch = @(
        @{ Field = 'FWBackup'; Wanted = $targetFWBackup; Actual = [string]$applied.FWBackup }
        @{ Field = 'JoinMethod'; Wanted = $targetJoinMethod; Actual = [string]$applied.JoinMethod }
        @{ Field = 'UseCentralReporting'; Wanted = $targetUseCentralReporting; Actual = [string]$applied.UseCentralReporting }
        @{ Field = 'CMStatus'; Wanted = $targetCMStatus; Actual = [string]$applied.CMStatus }
    ) | Where-Object { $_.Wanted -ne $_.Actual }

    # A service switched on lands on the undocumented value 'WaitingForApproval' first: the
    # request was accepted and now waits for a super admin to click 'Accept services' in the
    # Sophos Central console. That is a pending request, not a failed write, so it warns.
    # Anything else that did not take effect is the silent-no-op case and throws.
    $pending = @($mismatch | Where-Object { $_.Actual -eq 'WaitingForApproval' })
    $failed = @($mismatch | Where-Object { $_.Actual -ne 'WaitingForApproval' })

    foreach ($field in $pending) {
        Write-Warning "$($field.Field) was requested as '$($field.Wanted)' and now reads 'WaitingForApproval'. The service starts once a super admin confirms it in the Sophos Central console with 'Accept services'."
    }

    if ($failed) {
        $detail = ($failed | ForEach-Object { "$($_.Field): requested '$($_.Wanted)', firewall reports '$($_.Actual)'" }) -join '; '
        throw "The firewall reported success for the CentralManagement update but did not apply it - $detail. Switching a Sophos Central service on cannot be completed through the API alone; confirm it in the Sophos Central console instead."
    }
}

#endregion

#region SynchronizedSecurity

<#
.SYNOPSIS
    Retrieves the Synchronized Security settings of a Sophos Firewall.
 
.DESCRIPTION
    Reads the Security Heartbeat and Synchronized Application Control settings under
    SYSTEM > Sophos Central > Synchronized Security: whether each is on, the zones
    excluded from the heartbeat check, and how many months of application usage data the
    firewall keeps before purging it. Read-only; needs a connection from
    Connect-SfosFirewall and an account with administrative permission.
 
.PARAMETER Firewall
    Optional. Host name or IP address of the firewall. If omitted, the value from the
    current connection is used.
 
.PARAMETER Port
    Optional. TCP port of the management API, usually 4444. If omitted, the value from the
    current connection is used.
 
.PARAMETER Username
    Optional. User name for the web admin login. The account needs administrative
    permission. If omitted, the value from the current connection is used.
 
.PARAMETER Password
    Optional. Password for the web admin login, as a SecureString. If omitted, the value
    from the current connection is used.
 
.PARAMETER SkipCertificateCheck
    Optional. Accepts the firewall certificate without validating it. Use this only for
    appliances that still present a self-signed certificate. If omitted, the certificate is
    validated.
 
.PARAMETER Session
    Optional. A session object from Connect-SfosFirewall, or the name of a session that was
    registered with Connect-SfosFirewall -Name. Use it to address a specific firewall when
    you work with more than one at a time. Any connection parameter you pass explicitly
    still takes precedence. If omitted, the stored default connection is used.
 
.PARAMETER AcceptLoginDisclaimer
    Optional. Confirms a login disclaimer configured on the appliance, on behalf of the
    account logging in. Without this switch, this cmdlet fails on an appliance that shows a
    login disclaimer, with an error describing the disclaimer.
 
.INPUTS
    None. This cmdlet does not accept pipeline input.
 
.OUTPUTS
    System.Management.Automation.PSCustomObject. One object with the properties
    HeartbeatEnabled (bool), RestrictedZone (string array - the zones excluded from the
    heartbeat check, empty when none are configured), SyncAppControlEnabled (bool),
    AppsCleanupTimeframe (int - months of application usage data retained; 0 means the
    automatic cleanup is off) and UpdatedAt (datetime, UTC).
 
.NOTES
    Not part of the documented XML management API. Read through the web admin
    interface's own JSON REST endpoint (/synchronized-security/settings), the same call
    the web admin console itself makes; this interface is undocumented by the vendor and
    can change or break on a firmware update without notice.
 
.EXAMPLE
    Get-SfosSynchronizedSecurity
 
    Returns the current Synchronized Security settings of the firewall of the current
    connection.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
 
.LINK
    Set-SfosSynchronizedSecurity
#>

function Get-SfosSynchronizedSecurity {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string]$Firewall,
        [int]$Port,
        [string]$Username,
        [SecureString]$Password,
        [switch]$SkipCertificateCheck,
        [object]$Session,
        [switch]$AcceptLoginDisclaimer
    )

    $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters

    $webAdmin = Connect-SfosWebAdmin -Firewall $params.Firewall `
        -Port $params.Port `
        -Username $params.Username `
        -Password $params.Password `
        -SkipCertificateCheck:$params.SkipCertificateCheck -AcceptLoginDisclaimer:$AcceptLoginDisclaimer -ErrorAction Stop

    try {
        $response = Invoke-SfosWebAdminRestRequest -WebAdminSession $webAdmin -Path '/synchronized-security/settings' -Method Get -ErrorAction Stop
    }
    catch {
        throw "Failed to read Synchronized Security settings from $($params.Firewall): $($_.Exception.Message)"
    }

    if (-not $response -or $response.PSObject.Properties.Match('heartbeatEnabled').Count -eq 0) {
        throw "Failed to read Synchronized Security settings from $($params.Firewall): the web admin interface answered without the expected data. The interface may have changed."
    }

    $zoneNames = @()
    if ($response.PSObject.Properties.Match('restrictedZones').Count -gt 0 -and $response.restrictedZones) {
        $zoneNames = @($response.restrictedZones | ForEach-Object { [string]$_.name })
    }

    # updatedAt arrives as ISO-8601 with a trailing Z (UTC). ConvertFrom-Json behaves
    # differently between PowerShell versions here [measured]: PowerShell 7 parses it into a
    # [datetime] (Kind=Utc) automatically, PowerShell 5.1 leaves it as a [string]. Casting an
    # already-parsed [datetime] to [string] and re-parsing it is culture-dependent and unsafe -
    # observed to render as "02/07/2026" under one culture and "07.02.2026" under another for
    # the same value - so the type is checked first instead of assuming either shape.
    $updatedAt = $null
    if ($response.PSObject.Properties.Match('updatedAt').Count -gt 0 -and $response.updatedAt) {
        if ($response.updatedAt -is [datetime]) {
            $updatedAt = [datetime]::SpecifyKind($response.updatedAt, [DateTimeKind]::Utc)
        }
        else {
            $updatedAt = [datetime]::Parse(
                [string]$response.updatedAt,
                [Globalization.CultureInfo]::InvariantCulture,
                ([Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal))
        }
    }

    [PSCustomObject]@{
        HeartbeatEnabled      = [bool]$response.heartbeatEnabled
        RestrictedZone        = $zoneNames
        SyncAppControlEnabled = [bool]$response.syncAppControlEnabled
        AppsCleanupTimeframe  = [int]$response.appsCleanupTimeframe
        UpdatedAt             = $updatedAt
    }
}

<#
.SYNOPSIS
    Updates the Synchronized Security settings of a Sophos Firewall.
 
.DESCRIPTION
    Updates the Security Heartbeat and Synchronized Application Control settings under
    SYSTEM > Sophos Central > Synchronized Security. Reads the current settings first and
    resends the complete object, so anything not passed keeps its value; at least one
    functional parameter is required. Needs a connection from Connect-SfosFirewall and an
    account with administrative permission.
 
.PARAMETER HeartbeatEnabled
    Optional. Whether Security Heartbeat is on. If omitted, the current value is kept.
 
.PARAMETER RestrictedZone
    Optional. The zones excluded from the heartbeat check, replacing the whole list. Pass an
    empty array to clear it. If omitted, the current list is kept.
 
.PARAMETER SyncAppControlEnabled
    Optional. Whether Synchronized Application Control is on. If omitted, the current value
    is kept.
 
.PARAMETER AppsCleanupTimeframe
    Optional. Months of application usage data to retain before it is purged. The web admin
    console offers 1, 3, 6, 9 or 12 months, and uses 0 to mean the cleanup is off; other
    values were not observed and are refused. If omitted, the current value is kept.
 
.PARAMETER Firewall
    Optional. Host name or IP address of the firewall. If omitted, the value from the
    current connection is used.
 
.PARAMETER Port
    Optional. TCP port of the management API, usually 4444. If omitted, the value from the
    current connection is used.
 
.PARAMETER Username
    Optional. User name for the web admin login. The account needs administrative
    permission. If omitted, the value from the current connection is used.
 
.PARAMETER Password
    Optional. Password for the web admin login, as a SecureString. If omitted, the value
    from the current connection is used.
 
.PARAMETER SkipCertificateCheck
    Optional. Accepts the firewall certificate without validating it. Use this only for
    appliances that still present a self-signed certificate. If omitted, the certificate is
    validated.
 
.PARAMETER Session
    Optional. A session object from Connect-SfosFirewall, or the name of a session that was
    registered with Connect-SfosFirewall -Name. Use it to address a specific firewall when
    you work with more than one at a time. Any connection parameter you pass explicitly
    still takes precedence. If omitted, the stored default connection is used.
 
.PARAMETER AcceptLoginDisclaimer
    Optional. Confirms a login disclaimer configured on the appliance, on behalf of the
    account logging in. Without this switch, this cmdlet fails on an appliance that shows a
    login disclaimer, with an error describing the disclaimer.
 
.INPUTS
    None. This cmdlet does not accept pipeline input.
 
.OUTPUTS
    None. The cmdlet writes no output. It throws if the firewall rejects the update, and
    also if the firewall reports success but a requested value was not applied.
 
.NOTES
    Not part of the documented XML management API. Written through the web admin
    interface's own JSON REST endpoint (/synchronized-security/settings), the same call
    the web admin console itself makes; this interface is undocumented by the vendor and
    can change or break on a firmware update without notice.
 
    HeartbeatEnabled controls whether the firewall exchanges health status with Sophos
    Central-managed endpoints for use in firewall rules; switching it off removes that
    signal from every rule that depends on it. SyncAppControlEnabled controls Synchronized
    Application Control, which classifies traffic using data reported by managed
    endpoints; switching it off reverts affected traffic to standard application
    detection.
 
    After the write, the cmdlet reads the settings back and throws if a requested value
    did not take effect.
 
.EXAMPLE
    Set-SfosSynchronizedSecurity -SyncAppControlEnabled $true -WhatIf
 
    Shows what the call would change without sending it to the firewall.
 
.EXAMPLE
    Set-SfosSynchronizedSecurity -AppsCleanupTimeframe 6 -Confirm:$false
 
    Sets the application data retention period to 6 months, leaving Security Heartbeat,
    the restricted zone list and Synchronized Application Control untouched.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
 
.LINK
    Get-SfosSynchronizedSecurity
#>

function Set-SfosSynchronizedSecurity {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [bool]$HeartbeatEnabled,
        [string[]]$RestrictedZone,
        [bool]$SyncAppControlEnabled,

        [ValidateSet(0, 1, 3, 6, 9, 12)]
        [int]$AppsCleanupTimeframe,

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

    $bp = $PSBoundParameters
    $functionalParams = @('HeartbeatEnabled', 'RestrictedZone', 'SyncAppControlEnabled', 'AppsCleanupTimeframe')
    if (-not @($functionalParams | Where-Object { $bp.ContainsKey($_) })) {
        throw 'Nothing to change was specified. Provide at least one of -HeartbeatEnabled, -RestrictedZone, -SyncAppControlEnabled or -AppsCleanupTimeframe.'
    }

    $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters

    $webAdmin = Connect-SfosWebAdmin -Firewall $params.Firewall `
        -Port $params.Port `
        -Username $params.Username `
        -Password $params.Password `
        -SkipCertificateCheck:$params.SkipCertificateCheck -AcceptLoginDisclaimer:$AcceptLoginDisclaimer -ErrorAction Stop

    $existing = Get-SfosSynchronizedSecurity -Firewall $params.Firewall `
        -Port $params.Port `
        -Username $params.Username `
        -Password $params.Password `
        -SkipCertificateCheck:$params.SkipCertificateCheck -AcceptLoginDisclaimer:$AcceptLoginDisclaimer

    $targetHeartbeat = if ($bp.ContainsKey('HeartbeatEnabled')) { $HeartbeatEnabled } else { $existing.HeartbeatEnabled }
    # Wrap the whole if/else, not just each branch: an if/else result with zero or one
    # elements unwraps from an array to a scalar (or $null) on assignment - the same trap
    # documented for other modules in the project rules.
    $targetZones = @(if ($bp.ContainsKey('RestrictedZone')) { @($RestrictedZone) } else { @($existing.RestrictedZone) })
    $targetSyncAppControl = if ($bp.ContainsKey('SyncAppControlEnabled')) { $SyncAppControlEnabled } else { $existing.SyncAppControlEnabled }
    $targetCleanup = if ($bp.ContainsKey('AppsCleanupTimeframe')) { $AppsCleanupTimeframe } else { $existing.AppsCleanupTimeframe }

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

    $body = [ordered]@{
        heartbeatEnabled      = $targetHeartbeat
        restrictedZones       = @($targetZones | ForEach-Object { @{ name = $_ } })
        syncAppControlEnabled = $targetSyncAppControl
        appsCleanupTimeframe  = $targetCleanup
    }

    try {
        $result = Invoke-SfosWebAdminRestRequest -WebAdminSession $webAdmin -Path '/synchronized-security/settings' -Method Patch -Body $body -ErrorAction Stop
    }
    catch {
        throw "Failed to update Synchronized Security settings on $($params.Firewall): $($_.Exception.Message)"
    }

    if (-not $result -or $result.PSObject.Properties.Match('heartbeatEnabled').Count -eq 0) {
        throw "Update of Synchronized Security settings on $($params.Firewall) did not report the expected result: the web admin interface answered without the expected data."
    }

    $resultZones = @()
    if ($result.PSObject.Properties.Match('restrictedZones').Count -gt 0 -and $result.restrictedZones) {
        $resultZones = @($result.restrictedZones | ForEach-Object { [string]$_.name })
    }

    $mismatch = @()
    if ([bool]$result.heartbeatEnabled -ne $targetHeartbeat) { $mismatch += 'HeartbeatEnabled' }
    if ([bool]$result.syncAppControlEnabled -ne $targetSyncAppControl) { $mismatch += 'SyncAppControlEnabled' }
    if ([int]$result.appsCleanupTimeframe -ne $targetCleanup) { $mismatch += 'AppsCleanupTimeframe' }

    $expectedZoneSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$targetZones, [StringComparer]::OrdinalIgnoreCase)
    $resultZoneSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$resultZones, [StringComparer]::OrdinalIgnoreCase)
    if (-not $expectedZoneSet.SetEquals($resultZoneSet)) { $mismatch += 'RestrictedZone' }

    if ($mismatch.Count -gt 0) {
        throw "Synchronized Security settings on $($params.Firewall) did not take effect for: $($mismatch -join ', ')."
    }
}

#endregion