Private/ConvertTo-TripwireUtc.ps1
|
<# .SYNOPSIS Normalise any timestamp to a UTC DateTime. .DESCRIPTION Every time comparison in this module goes through here, and the reason is a bug this function exists to make impossible. Timestamps reach the engine from three places with three different .NET Kinds: a touch normalised in-process carries Kind=Utc, a decoy's plantedAt read back from JSON is a STRING, and a cast like [DateTime]'2026-06-15T09:00:00Z' produces Kind=Local -- it shifts the value into the machine's timezone. Subtract one from the other and the difference is wrong by the UTC offset, silently. On a machine at UTC-4 the 15-minute provisioning-grace window becomes a window that can never match, so the first run of every deployment pages somebody about its own planting; on a machine at UTC+0 the same code is correct and the bug is invisible. Kind=Unspecified is treated as UTC, which is what Microsoft Graph returns. #> function ConvertTo-TripwireUtc { [CmdletBinding()] [OutputType([DateTime])] param([Parameter(Mandatory)] [AllowNull()] $Value) if ($null -eq $Value) { return $null } if ($Value -is [DateTime]) { switch ($Value.Kind) { 'Utc' { return $Value } 'Local' { return $Value.ToUniversalTime() } default { return [DateTime]::SpecifyKind($Value, [DateTimeKind]::Utc) } } } if ($Value -is [DateTimeOffset]) { return $Value.UtcDateTime } $parsed = [DateTimeOffset]::Parse( [string]$Value, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal) return $parsed.UtcDateTime } <# .SYNOPSIS ConvertTo-TripwireUtc, but a timestamp that cannot be resolved returns $null instead of throwing. .DESCRIPTION Graph omits properties rather than nulling them, and a log record that reaches this module with no usable timestamp is a fact about the tenant, not a reason to abandon the run. ConvertTo-TripwireUtc throws on an unparseable value, which is right for code that cannot proceed without a time; every scoring path that CAN proceed -- by withholding the signals that need a clock -- goes through this one instead. The failure mode this replaces is the worst one a detector has: a single malformed record raised a terminating error out of Invoke-TripwireAnalysis and took every other scored trip in the batch with it, so a run that found intrusions returned nothing and looked exactly like a quiet tenant. Returns $null for: $null, an empty string, and anything that does not parse as a date. #> function ConvertTo-TripwireUtcOrNull { [CmdletBinding()] [OutputType([DateTime])] param([Parameter(Mandatory)] [AllowNull()] [AllowEmptyString()] $Value) if ($null -eq $Value) { return $null } if (($Value -is [string]) -and [string]::IsNullOrWhiteSpace($Value)) { return $null } try { return ConvertTo-TripwireUtc $Value } catch { return $null } } |