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,
        [Parameter(ParameterSetName = 'Build')] [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."
        }
        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 = @{}

    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 }

        $when = ConvertTo-TripwireUtc $r.createdDateTime
        $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]
            if ($null -eq $prev -or (ConvertTo-TripwireUtc $prev.timestamp) -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 ([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
}