Private/New-KrbRiskObject.ps1
|
#Requires -Version 7.4 function New-KrbRiskObject { <# .SYNOPSIS Constructs a KrbEtypeInsight.Risk object with the complete, uniform property set .DESCRIPTION The single place a risk object is built. Get-KrbEtypeRisk emits from three code paths - observed principals, principals whose configuration is dangerous but which produced no events, and trusts - and before this function existed each path assembled its own literal. They drifted, as parallel literals do. The consequence was not a missing value in a rarely-read field. The format file computes the NoAES column as @($_.ClientsWithoutAesSupport).Count, and in PowerShell @($null).Count is 1, not 0. A trust row that simply omitted the property therefore rendered as though one AES-incapable client depended on it. The number was invented by the absence of a property, which is the kind of defect that survives review because every individual line is correct. Every property is declared here with a default, so a caller that does not supply one gets an explicit empty collection rather than a missing member. That distinction is what makes @() count as zero and keeps every consumer - the format file, the CSV exporter, the HTML renderer and anyone piping to Select-Object - reading the same schema regardless of which path produced the row. .PARAMETER PrincipalName [System.String] (Mandatory, No Pipeline Support) The key the assessment is reported against: an account name, an unresolved service name, or a trust name. .PARAMETER Finding [System.Object[]] (Mandatory, No Pipeline Support) The findings for this principal, from New-KrbRiskFinding. .PARAMETER Summary [System.Object] (Mandatory, No Pipeline Support) The level and score, from Measure-KrbRiskLevel. .PARAMETER Role [System.String[]] (Optional, No Pipeline Support) Which roles the principal was observed in: Service, Client, Unobserved or Trust. .PARAMETER Observation [System.Object] (Optional, No Pipeline Support) The accumulated per-principal observation record, when there is one. Supplies request counts, observed encryption types, client lists and first and last seen. .PARAMETER Principal [System.Object] (Optional, No Pipeline Support) The matching directory object from Get-KrbPrincipalEtype, when one was resolved. .PARAMETER TargetEncryptionTypes [System.Object] (Optional, No Pipeline Support) The decoded target configuration the assessment was made against. .PARAMETER ConfiguredEncryptionTypes [System.Object] (Optional, No Pipeline Support) Decoded configuration for the principal. Supplied directly for trusts, which have no directory principal object. .PARAMETER ClientsWithoutAesSupport [System.String[]] (Optional, No Pipeline Support) Clients confirmed incapable of AES. Defaults to an empty array, never to null. .PARAMETER ClientsWithUnknownSupport [System.String[]] (Optional, No Pipeline Support) Clients whose capability could not be established. .PARAMETER ConfidenceNote [System.String[]] (Optional, No Pipeline Support) What was not measurable, and why. .PARAMETER BreakingCode [System.String[]] (Optional, No Pipeline Support) Finding codes that mean the change breaks this principal, as opposed to merely noting a weakness in it. WillBreakOnHardening is set from the intersection of these and the finding codes actually present. .PARAMETER CorrelationId [System.String] (Optional, No Pipeline Support) Correlation identifier for the assessment run. .EXAMPLE PS> New-KrbRiskObject -PrincipalName 'svc-payroll' -Finding $findings -Summary $summary ` -Role 'Service' -Observation $obs -TargetEncryptionTypes $target DESCRIPTION: Builds the risk object for an observed service OUTPUT: A KrbEtypeInsight.Risk object with the full property set USE CASE: Called by Get-KrbEtypeRisk for every emitted row .OUTPUTS KrbEtypeInsight.Risk .NOTES Author: Jeffrey Stuhr Blog: https://www.techbyjeff.net LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ TROUBLESHOOTING: - Report output: .\Troubleshooting\Common\Report-Output.md #> # Constructs an in-memory object and changes no system state. The Justification on a # SuppressMessageAttribute must be a single unwrappable literal, so it carries a summary. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Object constructor; changes no state.')] [CmdletBinding()] [OutputType('KrbEtypeInsight.Risk')] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$PrincipalName, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Finding, [Parameter(Mandatory)] [ValidateNotNull()] [object]$Summary, [Parameter()] [string[]]$Role = @(), [Parameter()] [object]$Observation, [Parameter()] [object]$Principal, [Parameter()] [object]$TargetEncryptionTypes, [Parameter()] [object]$ConfiguredEncryptionTypes, [Parameter()] [string[]]$ClientsWithoutAesSupport = @(), [Parameter()] [string[]]$ClientsWithUnknownSupport = @(), [Parameter()] [string[]]$ConfidenceNote = @(), [Parameter()] [string[]]$BreakingCode = @('KRB001', 'KRB002', 'KRB004', 'KRB005', 'KRB007', 'KRB011'), [Parameter()] [ValidateNotNullOrEmpty()] [string]$CorrelationId ) $codes = @($Finding.Code) $obs = $Observation # Every collection is built by direct assignment, never by an if/else expression. # # This is not a style preference. In PowerShell an if used as an expression takes its # value from the pipeline, and @() writes nothing to the pipeline - so # # $x = if ($cond) { @(1) } else { @() } # # assigns $null on the else branch, not an empty array. Written that way this constructor # reintroduced the exact defect it was created to fix: rows built without an observation # got $null for Clients, ServiceNames and the observed-etype collections, and every # consumer that counts them saw @($null).Count, which is 1. Direct assignment of @() to a # variable does produce an empty array, so the collections are established first and the # object literal only reads them. $serviceNames = @() if ($obs) { $serviceNames = @($obs.ServiceNames) } elseif ($Principal) { $serviceNames = @($Principal.ServicePrincipalNames) } $observedTicketEtypes = @() $observedSessionKeyEtypes = @() $observedPreAuthEtypes = @() $clients = @() $clientAddresses = @() $sources = @() if ($obs) { $observedTicketEtypes = @($obs.TicketEtypes | Sort-Object) $observedSessionKeyEtypes = @($obs.SessionKeyEtypes | Sort-Object) $observedPreAuthEtypes = @($obs.PreAuthEtypes | Sort-Object) $clients = @($obs.Clients) $clientAddresses = @($obs.ClientAddresses) $sources = @($obs.Sources) } # Ticket etypes are decoded to names here rather than at each call site, so the two # representations cannot disagree about which etypes a row observed. $observedNames = @( foreach ($etype in $observedTicketEtypes) { (ConvertFrom-KrbEtype -TicketEtype $etype).DisplayName } ) # Scalars are safe in an if/else because a scalar branch does write to the pipeline. $availableKeys = $null if ($obs -and $obs.AvailableKeysKnown) { $availableKeys = @($obs.AvailableKeys) } $configured = $null if ($ConfiguredEncryptionTypes) { $configured = $ConfiguredEncryptionTypes } elseif ($Principal) { $configured = $Principal.EncryptionTypes } [PSCustomObject]@{ PSTypeName = 'KrbEtypeInsight.Risk' PrincipalName = $PrincipalName Roles = @($Role) SamAccountName = if ($Principal) { $Principal.SamAccountName } else { $null } DistinguishedName = if ($Principal) { $Principal.DistinguishedName } else { $null } ObjectClass = if ($Principal) { $Principal.ObjectClass } else { $null } Enabled = if ($Principal) { $Principal.Enabled } else { $null } ResolvedInDirectory = [bool]$Principal RiskLevel = $Summary.Level RiskScore = $Summary.Score Findings = @($Finding) FindingCodes = $codes # True when at least one finding says the change breaks this principal, as opposed to # merely noting a weakness in it. WillBreakOnHardening = @($codes | Where-Object { $_ -in $BreakingCode }).Count -gt 0 ServiceNames = $serviceNames RequestCount = if ($obs) { $obs.RequestCount } else { 0 } FailureCount = if ($obs) { $obs.FailureCount } else { 0 } FirstSeen = if ($obs) { $obs.FirstSeen } else { $null } LastSeen = if ($obs) { $obs.LastSeen } else { $null } ObservedTicketEtypes = $observedTicketEtypes ObservedTicketEtypeNames = $observedNames ObservedSessionKeyEtypes = $observedSessionKeyEtypes ObservedPreAuthEtypes = $observedPreAuthEtypes # Null rather than @() when unknown: an empty key list means the principal holds no # keys at all, which is a finding, and must stay distinguishable from not having been # told. This is the one collection on the object where null is meaningful. AvailableKeys = $availableKeys ConfiguredEncryptionTypes = $configured TargetEncryptionTypes = $TargetEncryptionTypes ClientCount = if ($obs) { $obs.Clients.Count } else { 0 } Clients = $clients ClientAddresses = $clientAddresses ClientsWithoutAesSupport = @($ClientsWithoutAesSupport) ClientsWithUnknownSupport = @($ClientsWithUnknownSupport) EventSchemaVersion2 = if ($obs) { $obs.SawV2 } else { $false } ConfidenceNotes = @($ConfidenceNote) Sources = $sources AssessedAt = Get-Date CorrelationId = $CorrelationId } } |