Public/Get-TripwireBaseline.ps1
|
<# .SYNOPSIS Build, load or inspect the tenant baseline the scoring engine compares against. .DESCRIPTION The baseline is what turns "a sign-in from Latvia" into evidence or into Tuesday. It holds four things, all derived from the tenant's own sign-in history: countries / asns how often the tenant signs in from each country and autonomous system, so 'unusual' means unusual HERE hourHistogram 24 buckets of sign-in volume, so 'off hours' means off hours for this tenant rather than for an assumed office actorLastSeen each principal's last known position, for impossible travel suppressedActors operator-maintained allowlist of the tenant's own scanners Without a baseline the engine does not guess: the origin and off-hours signals are WITHHELD and every trip is marked degraded. That is the honest behaviour and it is visible in the report, rather than a quiet zero that reads as "nothing unusual". .PARAMETER Build Query the tenant's sign-in logs and construct a new baseline. .PARAMETER Days How far back to build from. 30 days is the default; Entra retains sign-in logs for 30 days on most licences, so asking for more usually returns less than you think. .PARAMETER Demo Load the synthetic baseline shipped with the module. .PARAMETER Save Write the built baseline to the profile state file. .EXAMPLE Get-TripwireBaseline -Build -Days 30 -Save .EXAMPLE (Get-TripwireBaseline -Demo).countries #> function Get-TripwireBaseline { [CmdletBinding(DefaultParameterSetName = 'Load')] [OutputType([pscustomobject])] param( [Parameter(ParameterSetName = 'Build', Mandatory)] [switch] $Build, # Same reason as Watch-TripwireActivity: a non-positive window silently builds a # baseline from zero records, and a zero baseline withholds two of the four signals # on every trip from then on. [Parameter(ParameterSetName = 'Build')] [ValidateRange(1, 365)] [int] $Days = 30, [Parameter(ParameterSetName = 'Build')] [switch] $Save, [Parameter(ParameterSetName = 'Load')] [switch] $Demo, [Parameter(ParameterSetName = 'Load')] [string] $Path ) if ($PSCmdlet.ParameterSetName -eq 'Load' -or -not $Build) { if (-not $Path) { $Path = if ($Demo) { Get-TripwireDemoPath -Name 'baseline.json' } else { Get-TripwireStatePath -Name 'baseline.json' } } $loaded = Read-TripwireJsonFile -Path $Path if ($null -eq $loaded) { Write-TripwireLog -Level 'Warn' -Message "No baseline at $Path. Origin and off-hours signals will be WITHHELD and every trip marked degraded. Run Get-TripwireBaseline -Build -Save." } elseif (-not $Demo) { # Said at load time, against the clock, because this is the moment the operator # is looking. The scoring engine asks the same question again per touch and # against the touch's own time, which is the one that decides whether a signal # is withheld; the default policy is used here because loading a baseline is not # a scoring call and carries no -Policy. # # Skipped for -Demo: those fixtures are deliberately frozen in 2026-06, so an # age measured against today's date would be an artefact of the calendar rather # than a fact about a tenant. $freshness = Test-TripwireBaselineFreshness -Baseline $loaded -AsOf $null -Policy (Get-TripwirePolicy) if ($freshness.Stale) { Write-TripwireLog -Level 'Warn' -Message "The baseline at $Path is NOT usable as it stands: $($freshness.Reason) Origin and off-hours will be WITHHELD and every trip marked degraded until it is rebuilt." } elseif ($freshness.Warn) { Write-TripwireLog -Level 'Warn' -Message ("The baseline at {0} is {1:N1} day(s) old. It is still being used, but 'unusual country' and 'outside working hours' are measured against the tenant as it was then. Rebuild it with Get-TripwireBaseline -Build -Save." -f $Path, $freshness.AgeDays) } } return $loaded } $since = [DateTime]::UtcNow.AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ') $uri = "/v1.0/auditLogs/signIns?`$top=999&`$filter=createdDateTime ge $since" Write-TripwireLog -Message "Building baseline from sign-ins since $since" $records = @(Invoke-TripwireGraphRequest -Uri $uri -MaxPages 200) $countries = @{} $asns = @{} $hours = @(0) * 24 $lastSeen = @{} $undated = 0 foreach ($r in $records) { # Only successful interactive sign-ins describe where the tenant actually works. # Failures describe where it is being attacked from, and folding those into the # baseline would teach the detector that the attacker's country is normal. if ($null -eq $r.status -or [int]$r.status.errorCode -ne 0) { continue } # A record with no usable date cannot go in the hour histogram -- and must not # take the rest of the baseline down with it, which an unguarded parse did. # It is counted and reported, never silently discarded. $when = ConvertTo-TripwireUtcOrNull $r.createdDateTime if ($null -eq $when) { $undated++; continue } $hours[$when.Hour] += 1 $c = if ($null -ne $r.location) { [string]$r.location.countryOrRegion } else { '' } if (-not [string]::IsNullOrWhiteSpace($c)) { if (-not $countries.ContainsKey($c)) { $countries[$c] = 0 } $countries[$c] += 1 } $a = [string]$r.autonomousSystemNumber if (-not [string]::IsNullOrWhiteSpace($a)) { if (-not $asns.ContainsKey($a)) { $asns[$a] = 0 } $asns[$a] += 1 } $uid = [string]$r.userId if (-not [string]::IsNullOrWhiteSpace($uid) -and $null -ne $r.location -and $null -ne $r.location.geoCoordinates) { $prev = $lastSeen[$uid] $prevAt = if ($null -ne $prev) { ConvertTo-TripwireUtcOrNull $prev.timestamp } else { $null } if ($null -eq $prev -or $null -eq $prevAt -or $prevAt -lt $when) { $lastSeen[$uid] = @{ timestamp = $when.ToString('o') country = $c latitude = $r.location.geoCoordinates.latitude longitude = $r.location.geoCoordinates.longitude } } } } $baseline = [pscustomobject]@{ generatedAt = [DateTime]::UtcNow.ToString('o') windowDays = $Days totalSignIns = ($hours | Measure-Object -Sum).Sum countries = $countries asns = $asns hourHistogram = $hours actorLastSeen = $lastSeen suppressedActors = @() } if ($undated -gt 0) { Write-TripwireLog -Level 'Warn' -Message "$undated successful sign-in(s) carried no usable timestamp and are not in the hour histogram. The off-hours signal is calibrated on the rest." } if ([int]$baseline.totalSignIns -eq 0) { Write-TripwireLog -Level 'Warn' -Message 'The baseline contains zero successful sign-ins. This is NOT a quiet tenant -- it usually means the query returned nothing (missing AuditLog.Read.All, or no Entra ID P1/P2 licence, which sign-in log retention requires). Do not save it.' } if ($Save) { $out = Get-TripwireStatePath -Name 'baseline.json' $baseline | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $out -Encoding utf8 Write-TripwireLog -Message "Baseline saved to $out ($($baseline.totalSignIns) sign-ins)." } return $baseline } |