core/Remove-VolatileKeys.ps1

#Requires -Version 7.0

<#
.SYNOPSIS
Removes volatile/non-configuration keys from a PowerShell object.

.DESCRIPTION
Strips out volatile keys like timestamps, IDs, and metadata that should not be backed up.
Works recursively on nested objects and arrays. Creates a deep clone to avoid mutating originals.
Supports pipeline input of individual objects (each piped item is processed separately).

.PARAMETER InputObject
The object to clean. Can be a PSCustomObject, hashtable, array, or null.

.PARAMETER AdditionalExcludedProperties
Additional property names to exclude in addition to the built-in volatile keys.

.EXAMPLE
$cleanObj = $obj | Remove-VolatileKeys

.EXAMPLE
$arr | Remove-VolatileKeys # processes each array element individually via pipeline

.EXAMPLE
Remove-VolatileKeys -InputObject $obj -AdditionalExcludedProperties @('id', 'description')
#>


$script:VolatileKeys = @(
    'version',
    'topicIdentifier',
    'certificate',
    'createdDateTime',
    'lastModifiedDateTime',
    'isAssigned',
    '@odata.context',
    'sourceId',
    'supportsScopeTags',
    'companyCodes',
    'isGlobalScript',
    'highestAvailableVersion',
    'token',
    'lastSyncDateTime',
    'isReadOnly',
    'secretReferenceValueId',
    'isEncrypted',
    'modifiedDateTime',
    'deployedAppCount',
    'intunecd_name',
    'deviceHealthScriptType',
    'scheduledActionsForRule@odata.context',
    'scheduledActionConfigurations@odata.context',
    'assignments@odata.context'
)

function Remove-VolatileKeysFromObject {
    [CmdletBinding()]
    param(
        $obj,
        [System.Collections.Generic.HashSet[int]]$RecursionStack,
        [System.Collections.Generic.HashSet[string]]$ExcludedKeys
    )

    if ($null -eq $obj) {
        return $null
    }

    # keep primitives as-is
    if ($obj.GetType().IsValueType -or $obj -is [string]) {
        return $obj
    }

    if ($null -eq $RecursionStack) {
        $RecursionStack = [System.Collections.Generic.HashSet[int]]::new()
    }

    # guard against circular references in object graphs
    $objectId = [System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($obj)
    if ($RecursionStack.Contains($objectId)) {
        return $null
    }

    $addedToStack = $RecursionStack.Add($objectId)

    try {
        # handle hashtables
        if ($obj -is [hashtable]) {
            $cleaned = [ordered]@{}
            foreach ($key in $obj.Keys) {
                if (-not $ExcludedKeys.Contains([string]$key)) {
                    $cleaned[$key] = Remove-VolatileKeysFromObject -obj $obj[$key] -RecursionStack $RecursionStack -ExcludedKeys $ExcludedKeys
                }
            }
            return $cleaned
        }

        # handle PSCustomObject
        if ($obj -is [PSCustomObject]) {
            $cleaned = [PSCustomObject]@{}
            foreach ($prop in $obj.PSObject.Properties) {
                if (-not $ExcludedKeys.Contains($prop.Name)) {
                    $cleaned | Add-Member -MemberType NoteProperty -Name $prop.Name -Value (Remove-VolatileKeysFromObject -obj $prop.Value -RecursionStack $RecursionStack -ExcludedKeys $ExcludedKeys)
                }
            }
            return $cleaned
        }

        # handle arrays (but not strings or dictionaries)
        if ($obj -is [System.Collections.IEnumerable] -and $obj -isnot [string] -and $obj -isnot [System.Collections.IDictionary]) {
            $array = [System.Collections.Generic.List[object]]::new()
            foreach ($item in $obj) {
                $array.Add((Remove-VolatileKeysFromObject -obj $item -RecursionStack $RecursionStack -ExcludedKeys $ExcludedKeys))
            }
            return , $array.ToArray()
        }

        # primitive/other object types — return as-is
        return $obj
    }
    finally {
        if ($addedToStack) {
            [void]$RecursionStack.Remove($objectId)
        }
    }
}

function Remove-VolatileKeys {
    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline = $true)]
        [AllowNull()]
        $InputObject,

        [string[]]$AdditionalExcludedProperties = @()
    )

    begin {
        $collected = [System.Collections.Generic.List[object]]::new()
        $excludedKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($key in $script:VolatileKeys + $AdditionalExcludedProperties) {
            if (-not [string]::IsNullOrWhiteSpace($key)) {
                [void]$excludedKeys.Add($key)
            }
        }
    }

    process {
        $stack = [System.Collections.Generic.HashSet[int]]::new()
        $collected.Add((Remove-VolatileKeysFromObject -obj $InputObject -RecursionStack $stack -ExcludedKeys $excludedKeys))
    }

    end {
        if ($collected.Count -eq 0) {
            return
        }
        elseif ($collected.Count -eq 1) {
            return $collected[0]
        }
        else {
            return , $collected.ToArray()
        }
    }
}

Export-ModuleMember -Function Remove-VolatileKeys