Private/Measure-TripwireSignal.ps1

<#
    The four evidence signals, and the suppression rules that run before any of them.

    Design rule for this whole file: a signal that cannot be evaluated returns
    Available = $false and contributes zero. It never returns a neutral-looking 0 that is
    indistinguishable from "evaluated, found nothing". The composed score carries the list
    of withheld signals so a human -- and Invoke-TripwireContainment -- can tell a quiet
    tenant apart from a blind detector.
#>


<#
.SYNOPSIS
    Signal (a): has this decoy ever been used legitimately?

.DESCRIPTION
    A decoy is only evidence to the degree that nothing legitimate touches it. The moment
    a backup job, a licensing script or a curious administrator starts hitting it, every
    subsequent touch is ambiguous -- and this signal is the one that carries the most
    weight, so its decay is what stops a polluted decoy from driving containment.

    LegitimateUseCount is maintained by the operator (Set it on the decoy record when you
    find a legitimate consumer). It is deliberately not inferred from the logs: inferring
    it would let an attacker who touches a decoy repeatedly *lower* its own score.
#>

function Measure-TripwireDecoyPuritySignal {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [AllowNull()] $Decoy,
        [Parameter(Mandatory)] $Policy
    )

    $weight = $Policy.Weights.DecoyPurity

    if ($null -eq $Decoy) {
        return New-TripwireSignal -Name 'DecoyPurity' -Weight $weight -Available $false `
            -Rationale 'The touched object is not in the decoy inventory, so its purity is unknown.'
    }

    $uses = $Decoy.legitimateUseCount
    if ($null -eq $uses) { $uses = 0 }
    $uses = [int]$uses

    foreach ($tier in $Policy.PurityTiers) {
        if ($uses -le $tier.MaxUses) {
            $rationale = if ($uses -eq 0) {
                "Decoy '$($Decoy.displayName)' has no recorded legitimate use since it was planted."
            }
            else {
                "Decoy '$($Decoy.displayName)' has $uses recorded legitimate use(s); a touch is weaker evidence."
            }
            return New-TripwireSignal -Name 'DecoyPurity' -Weight $weight -Raw $tier.Raw -Rationale $rationale
        }
    }

    return New-TripwireSignal -Name 'DecoyPurity' -Weight $weight -Raw 0.0 `
        -Rationale "Decoy '$($Decoy.displayName)' has $uses recorded legitimate uses. It is contaminated and carries no evidential weight; re-plant it."
}

<#
.SYNOPSIS
    Signal (b): is the origin of this touch anomalous for this tenant?

.DESCRIPTION
    Three components, strongest wins:

      1. Impossible travel -- the same actor was seen elsewhere in the baseline and the
         implied ground speed exceeds the policy limit. Decisive when it fires.
      2. A country the tenant has never signed in from, or one that accounts for less
         than RareCountryShare of baseline volume.
      3. An autonomous system the tenant has never used, or a rare one. Weaker than
         country on its own: cloud egress legitimately moves between ASNs.

    With no baseline, this is WITHHELD. The temptation is to treat an unknown origin as
    anomalous, and that is backwards -- on a tenant with no history, every origin is
    unknown, and the detector would fire on all of them at once on day one.
#>

function Measure-TripwireOriginSignal {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] $Touch,
        [AllowNull()] $Baseline,
        [Parameter(Mandatory)] $Policy
    )

    $weight = $Policy.Weights.OriginAnomaly

    $total = if ($null -ne $Baseline) { $Baseline.totalSignIns } else { $null }
    if ($null -eq $Baseline -or $null -eq $total -or [int]$total -le 0) {
        return New-TripwireSignal -Name 'OriginAnomaly' -Weight $weight -Available $false `
            -Rationale 'No tenant baseline of sign-in origins, so "unusual origin" has no meaning yet. Run Get-TripwireBaseline first.'
    }
    $total = [int]$total

    # 1. Impossible travel against the actor's last known position.
    $lastSeen = Get-TripwireMapValue -Map $Baseline.actorLastSeen -Key ([string]$Touch.actor.id)
    if ($null -ne $lastSeen -and $null -ne $Touch.latitude -and $null -ne $lastSeen.latitude) {
        $km = Get-TripwireDistanceKm -Lat1 $lastSeen.latitude -Lon1 $lastSeen.longitude `
            -Lat2 $Touch.latitude -Lon2 $Touch.longitude
        $hours = ((ConvertTo-TripwireUtc $Touch.timestamp) - (ConvertTo-TripwireUtc $lastSeen.timestamp)).TotalHours
        if ($null -ne $km -and $hours -gt 0) {
            $kph = $km / $hours
            if ($kph -gt $Policy.ImpossibleTravelKph) {
                return New-TripwireSignal -Name 'OriginAnomaly' -Weight $weight -Raw 1.0 -Rationale (
                    'Impossible travel: {0} km from the previous sign-in in {1} h implies at least {2} km/h (limit {3}).' -f `
                        [Math]::Round($km), [Math]::Round($hours, 2), [Math]::Round($kph), $Policy.ImpossibleTravelKph)
            }
        }
    }

    # No origin enrichment at all (directory audits carry none) means the origin CANNOT be
    # judged. Scoring that as 0.0 would report "normal origin" about an origin nobody saw.
    $hasCountry = -not [string]::IsNullOrWhiteSpace([string]$Touch.country)
    $hasAsn = -not [string]::IsNullOrWhiteSpace([string]$Touch.asn)
    if (-not $hasCountry -and -not $hasAsn) {
        return New-TripwireSignal -Name 'OriginAnomaly' -Weight $weight -Available $false `
            -Rationale 'This log source carries no country or autonomous-system enrichment, so the origin cannot be compared with the tenant baseline.'
    }

    $best = 0.0
    $reason = "Origin $($Touch.country)/AS$($Touch.asn) is normal for this tenant."

    # 2. Country.
    $country = [string]$Touch.country
    if ($hasCountry) {
        $seen = Get-TripwireMapValue -Map $Baseline.countries -Key $country
        if ($null -eq $seen -or [int]$seen -le 0) {
            $best = $Policy.OriginRaw.UnseenCountry
            $reason = "Country '$country' has never appeared in $total baseline sign-ins."
        }
        elseif (([double]$seen / $total) -lt $Policy.RareCountryShare) {
            $best = $Policy.OriginRaw.RareCountry
            $reason = ('Country ''{0}'' accounts for {1:P2} of baseline sign-ins.' -f $country, ([double]$seen / $total))
        }
    }

    # 3. Autonomous system.
    $asn = [string]$Touch.asn
    if ($hasAsn) {
        $seenAsn = Get-TripwireMapValue -Map $Baseline.asns -Key $asn
        $asnRaw = 0.0
        $asnReason = $null
        if ($null -eq $seenAsn -or [int]$seenAsn -le 0) {
            $asnRaw = $Policy.OriginRaw.UnseenAsn
            $asnReason = "AS$asn has never appeared in $total baseline sign-ins."
        }
        elseif (([double]$seenAsn / $total) -lt $Policy.RareAsnShare) {
            $asnRaw = $Policy.OriginRaw.RareAsn
            $asnReason = ('AS{0} accounts for {1:P2} of baseline sign-ins.' -f $asn, ([double]$seenAsn / $total))
        }
        if ($asnRaw -gt $best) { $best = $asnRaw; $reason = $asnReason }
    }

    return New-TripwireSignal -Name 'OriginAnomaly' -Weight $weight -Raw $best -Rationale $reason
}

<#
.SYNOPSIS
    Signal (c): how many DISTINCT decoys did this actor touch inside the correlation window?

.DESCRIPTION
    One decoy touched once is a lead. Several decoys touched by one actor in an hour is
    enumeration -- nothing legitimate walks a set of objects that no legitimate process
    knows about. This is the strongest behavioural signal in the engine and the reason a
    deployment should plant more than one decoy.

    The count is of distinct decoy ids, never of events. An attacker retrying one decoy
    fifty times must not look like a fan-out across fifty decoys.
#>

function Measure-TripwireFanOutSignal {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [int] $DistinctDecoyCount,
        [Parameter(Mandatory)] $Policy
    )

    $weight = $Policy.Weights.CrossDecoyFanOut
    $raw = 0.0
    foreach ($tier in $Policy.FanOutTiers) {
        if ($DistinctDecoyCount -ge $tier.MinDecoys) { $raw = $tier.Raw }
    }

    $rationale = if ($DistinctDecoyCount -le 1) {
        'A single decoy was touched by this actor inside the correlation window; no fan-out.'
    }
    else {
        "$DistinctDecoyCount distinct decoys were touched by the same actor inside the correlation window -- consistent with enumeration."
    }

    return New-TripwireSignal -Name 'CrossDecoyFanOut' -Weight $weight -Raw $raw -Rationale $rationale
}

<#
.SYNOPSIS
    Signal (d): how unusual is this hour for this tenant?

.DESCRIPTION
    Scored against the tenant's own 24-hour sign-in histogram, not against an assumption
    about office hours: a tenant whose staff work nights must not be permanently suspicious
    of itself.

    This carries the smallest weight on purpose. Legitimate administrators do work at
    03:00, and a detector that treats the hour as strong evidence trains its operators to
    dismiss it. See docs/scoring.md for the invariant that keeps it honest: no single
    signal, this one included, can reach the containment band alone.
#>

function Measure-TripwireOffHoursSignal {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] $Touch,
        [AllowNull()] $Baseline,
        [Parameter(Mandatory)] $Policy
    )

    $weight = $Policy.Weights.OffHours
    $hist = if ($null -ne $Baseline) { $Baseline.hourHistogram } else { $null }

    if ($null -eq $hist -or @($hist).Count -ne 24) {
        return New-TripwireSignal -Name 'OffHours' -Weight $weight -Available $false `
            -Rationale 'No 24-hour baseline histogram, so this hour cannot be compared with the tenant norm.'
    }

    $values = @($hist | ForEach-Object { [double]$_ })
    $peak = ($values | Measure-Object -Maximum).Maximum
    if ($peak -le 0) {
        return New-TripwireSignal -Name 'OffHours' -Weight $weight -Available $false `
            -Rationale 'The baseline histogram is empty, so no hour is more normal than another.'
    }

    $hour = (ConvertTo-TripwireUtc $Touch.timestamp).Hour
    $density = $values[$hour] / $peak
    $raw = 1.0 - $density

    return New-TripwireSignal -Name 'OffHours' -Weight $weight -Raw $raw -Rationale (
        '{0:00}:00 UTC runs at {1:P0} of this tenant''s busiest hour.' -f $hour, $density)
}

<#
.SYNOPSIS
    Hard suppression rules, evaluated before any signal.

.DESCRIPTION
    These are the known-benign causes of a decoy touch. They return a reason string, and a
    suppressed trip scores zero regardless of how anomalous it looks -- which is the point:
    a scanner sweeping every object in the directory at 04:00 from a datacentre ASN would
    otherwise light up every signal at once.

    Returns $null when nothing suppresses the event.
#>

function Test-TripwireSuppression {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] $Touch,
        [AllowNull()] $Decoy,
        [AllowNull()] $Baseline,
        [Parameter(Mandatory)] $Policy
    )

    $actorId = [string]$Touch.actor.id

    # 1. Operator-maintained allowlist: the tenant's own scanners, CMDB sync, DLP tooling.
    if ($null -ne $Baseline -and $null -ne $Baseline.suppressedActors) {
        foreach ($allowed in @($Baseline.suppressedActors)) {
            if (-not [string]::IsNullOrWhiteSpace($actorId) -and
                [string]$allowed -eq $actorId) {
                return "Actor $actorId is on the tenant suppression allowlist."
            }
        }
    }

    # 2. Provisioning self-noise. Planting a decoy writes an audit record in which the
    # planter touches the decoy. Without this rule every deployment starts with a page.
    if ($null -ne $Decoy -and $null -ne $Decoy.plantedAt -and -not [string]::IsNullOrWhiteSpace([string]$Decoy.plantedBy)) {
        $sincePlant = ((ConvertTo-TripwireUtc $Touch.timestamp) - (ConvertTo-TripwireUtc $Decoy.plantedAt)).TotalMinutes
        $planter = [string]$Decoy.plantedBy
        $isPlanter = ($planter -eq $actorId) -or ($planter -eq [string]$Touch.actor.upn)
        if ($isPlanter -and $sincePlant -ge 0 -and $sincePlant -le $Policy.ProvisioningGraceMinutes) {
            return "Within the $($Policy.ProvisioningGraceMinutes)-minute provisioning window and performed by the planter ($planter)."
        }
    }

    return $null
}