Private/Get-JIMRedactedBody.ps1

# Copyright (c) Tetron Limited. All rights reserved.
# Licensed under the Tetron Commercial License. See LICENSE file in the project root.

function Get-JIMRedactedBody {
    <#
    .SYNOPSIS
        Renders a request body for the debug stream with every credential-shaped value replaced.
 
    .DESCRIPTION
        Invoke-JIMApi writes the outgoing body to Write-Debug so an operator can see what a cmdlet
        sent. Several cmdlets send a password: Set-JIMMetaverseObjectPassword,
        Set-JIMConnectedSystemObjectPassword and Set-JIMSyncRuleInitialPassword each take one as a
        SecureString, precisely to keep it out of
        the session history, and then have to unwrap it to put it on the wire. Logging the body
        undid that: the value the SecureString protected went to the debug stream in clear text, and
        into any transcript running at the time.
 
        JIM's never-log invariant is that no password value reaches any log, at any level. The
        server side has always honoured it; this is the client side of the same rule.
 
        Redaction is by property name, applied recursively, and is deliberately conservative:
 
        - Names matching the credential heuristic below are replaced wholesale. The list mirrors the
          spirit of CredentialAttributes.HasCredentialLikeName on the server: broad, and erring
          towards redacting something harmless rather than missing something that matters.
        - Every stringValue is replaced, whatever it holds. Connected System setting values are the
          case that settles this: a service account's password travels as a stringValue keyed by
          setting identifier, exactly as a hostname or a base DN does, so the payload gives no way
          to tell them apart. Losing a base DN from a debug line costs less than leaking the
          credential beside it, and the setting identifiers and non-string values still show, which
          is most of what the line is read for.
        - The replacement is a fixed marker, never derived from the value, so nothing about the
          original (its length above all) survives.
 
        A body that arrives already serialised is parsed and redacted the same way. One that cannot
        be parsed is suppressed entirely rather than logged raw: an unparseable body cannot be
        inspected, so it cannot be shown to be free of secrets.
 
    .PARAMETER Body
        The request body, as a hashtable, PSCustomObject, array or already-serialised JSON string.
 
    .OUTPUTS
        A JSON string safe to write to the debug stream.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter()]
        [AllowNull()]
        [object]$Body
    )

    if ($null -eq $Body) {
        return '<no body>'
    }

    if ($Body -is [string]) {
        try {
            $parsed = $Body | ConvertFrom-Json -ErrorAction Stop
        }
        catch {
            return '<body suppressed: not JSON, so it cannot be shown to hold no secret>'
        }

        return (Get-JIMRedactedValue -Value $parsed | ConvertTo-Json -Depth 10 -Compress)
    }

    return (Get-JIMRedactedValue -Value $Body | ConvertTo-Json -Depth 10 -Compress)
}

function Get-JIMRedactedValue {
    <#
    .SYNOPSIS
        Recursive worker for Get-JIMRedactedBody: returns a copy of a value with credential-shaped
        properties replaced.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        [object]$Value,

        [int]$Depth = 0
    )

    # A body deep enough to hit this is malformed rather than merely nested; ConvertTo-Json stops at
    # the same sort of bound. Returning the marker keeps the guard from being a way to smuggle a
    # value past the redaction by burying it.
    if ($Depth -gt 12) {
        return $script:JIMRedactionMarker
    }

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

    # Strings, numbers and booleans have no property names to judge, so the caller decided about
    # them; here they pass through as they are.
    if ($Value -is [string] -or $Value.GetType().IsPrimitive -or $Value -is [datetime] -or $Value -is [guid]) {
        return $Value
    }

    if ($Value -is [System.Collections.IDictionary]) {
        $copy = @{}
        foreach ($key in $Value.Keys) {
            $copy[$key] = if (Test-JIMRedactedName -Name ([string]$key)) {
                $script:JIMRedactionMarker
            }
            else {
                Get-JIMRedactedValue -Value $Value[$key] -Depth ($Depth + 1)
            }
        }
        return $copy
    }

    if ($Value -is [System.Collections.IEnumerable]) {
        # Rebuilt as an array so a single-element collection does not unroll into a bare value and
        # change the shape of what is logged.
        return @($Value | ForEach-Object { Get-JIMRedactedValue -Value $_ -Depth ($Depth + 1) })
    }

    if ($Value -is [System.Management.Automation.PSCustomObject]) {
        $copy = @{}
        foreach ($property in $Value.PSObject.Properties) {
            $copy[$property.Name] = if (Test-JIMRedactedName -Name $property.Name) {
                $script:JIMRedactionMarker
            }
            else {
                Get-JIMRedactedValue -Value $property.Value -Depth ($Depth + 1)
            }
        }
        return $copy
    }

    # Anything else is a type this function does not understand well enough to prove is safe.
    return $script:JIMRedactionMarker
}

function Test-JIMRedactedName {
    <#
    .SYNOPSIS
        Whether a property of this name carries a value that must never reach a log.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Name
    )

    # Anchored on the whole name rather than searched within it, so a name that merely *mentions*
    # credentials keeps its value: passwordSynchronisationEnabled is a state and
    # passwordExpiryBehaviour is a policy, and both are worth seeing in a debug line. A name that
    # ends in one of these words is the value itself (bindPassword, clientSecret, staticPassword).
    return $Name -match '(?i)^(.*(password|passwd|pwd|secret|credential|passphrase|apikey|token|privatekey)|stringvalue)$'
}