Private/TimedEvent.ps1

# The Timed Event primitive.
#
# A Timed Event is one thing a Check read out of a log that happened at a time: a
# connection to a Server that was lost at 08:05 on the third, the machine's network going
# away, a program that crashed, a sign-in. It is what the Report's 30 days are drawn from,
# beside the Findings that count the same events in words. See ADR-0010.
#
# Not a Measurement: a Measurement is one number for one subject and one metric, and has
# no time. Not a row of events.csv either: that is every event the Checks read, as Windows
# wrote it, and says neither which lane it belongs in nor which Finding judged its kind -
# only the Judge knows that (ADR-0002). A Judge returns it among its Findings, as it does
# its Measurements, and the Run tells the three apart by type.
#
# Lane and Mark are closed vocabularies, for the reason a Measurement's Metric is one: a
# misspelt Mark would be a lane of its own.

# Where the timeline draws it, and what it is there. A Server's lane holds what happened
# to the connections to that Server; the machine's lane its own network going away; the
# markers what happened to programs and Users on the same axis; and a log's reach is how
# far back the events of that log can be there at all.
$script:TimedEventMarks = [ordered]@{
    Server  = @('Lost', 'Failed', 'ShareNotFound', 'Denied', 'Context')
    Machine = @('Lost', 'Context')
    Marker  = @('Crash', 'Hang', 'Start', 'SignIn', 'PasswordSet', 'LockedOut', 'BadPassword')
    Log     = @('Reaches', 'Unread')
}

# The Marks of the Marker lane that say what happened to the User's domain account: when its
# password was set, when it was locked out, when the last wrong password was tried. The
# Timeline draws them in a row of their own (ReportNetworkTimeline.ps1).
$script:TimedEventAccountMarks = @('PasswordSet', 'LockedOut', 'BadPassword')

# The HTTP statuses by which a Service that answered refused the identity it was given: it
# wants a sign-in (401), or will not let this one in (403). A proxy's 407 is the proxy's, not
# the Service's, and is a request that Failed.
$script:TimedEventDeniedStatus = @(401, 403)

function Get-TimedEventStatusMark {
    <#
    .SYNOPSIS
        The Mark of an event on a Server's lane by the HTTP status it ended with: Denied where
        the Service answered and refused the identity, Failed for any other status or none.
        Pure.
    .DESCRIPTION
        One rule for every Kind that reads a status - an application's log, the Nextcloud
        journal - so that a 401 reads the same wherever it was found. ADR-0010.
    .PARAMETER Status
        The HTTP status as it was read: a number, or text holding one.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Status)

    $number = ConvertTo-Number "$Status".Trim()
    if ($null -ne $number -and $number -in $script:TimedEventDeniedStatus) { return 'Denied' }
    'Failed'
}

function Get-TimedEventDeniedSignal {
    <#
    .SYNOPSIS
        The Signal service-denied where any of the Timed Events a Finding counts is Denied,
        else nothing. Pure.
    .DESCRIPTION
        Read off the events as the timeline draws them, so that a Finding says a Service
        refused the sign-in exactly where a hollow stroke shows it. ADR-0005, ADR-0010.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()]$TimedEvent)

    if (@($TimedEvent | Where-Object { $_ -and "$($_.Lane)" -eq 'Server' -and "$($_.Mark)" -eq 'Denied' }).Count) { 'service-denied' }
}

function New-TimedEvent {
    <#
    .SYNOPSIS
        One thing that happened at a time, as the Report's timeline draws it. Pure.
    .DESCRIPTION
        Validated here, like a Finding: an unknown Lane, or a Mark that is not of its Lane,
        throws where the Judge is written. Every event has a time, but for a log that could
        not be read: that one reaches back to nothing.
    .PARAMETER Time
        When it happened; for a log's reach, the time of the oldest record the log holds.
    .PARAMETER Lane
        Server, Machine, Marker or Log.
    .PARAMETER Mark
        What it is in its Lane. Server: Lost, Failed, ShareNotFound, Denied, Context. Machine: Lost,
        Context. Marker: Crash, Hang, Start, SignIn, and of the User's domain account
        PasswordSet, LockedOut, BadPassword. Log: Reaches, Unread. Context is an event shown
        and not counted: a resume, a link that came back.
    .PARAMETER Subject
        The Server as the event named it - a name, an FQDN or an address -, the program that
        crashed, the account that signed in or was locked out. Empty for the machine itself.
    .PARAMETER Address
        The Server's address, where the event or a lookup the Kind already made has it. Not
        looked up for this: the timeline merges a Server by its address first and its name
        second, as the path does.
    .PARAMETER Detail
        What it was, in words: the kind of the event as the Report calls it.
    .PARAMETER Log
        The event log it was read from, which a log's reach is about.
    .PARAMETER Finding
        The Finding that judged this kind of event, whose Severity the timeline colours it
        with: the Finding itself, or its Id as it arrived from the Elevated Part. Nothing
        where no Finding judges it, and the timeline then draws it grey.
    .PARAMETER Port
        The port of the Service on the Server it happened to, where the Judge knows it by its
        log or its kind: SMB 445, RDP 3389, Kerberos 88. The Network Map puts it on that
        Service as well as on the Server. Nothing where it is not known.
    .PARAMETER Url
        The URL of the Service it happened to, absolute, where the Judge knows it: an
        event of a URL Service is that Service's, as a Measurement of it is by its Group.
        Nothing where it is not known.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Time,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Lane,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Mark,
        [AllowNull()][AllowEmptyString()][string]$Subject = '',
        [AllowNull()][AllowEmptyString()][string]$Address = '',
        [AllowNull()][AllowEmptyString()][string]$Detail = '',
        [AllowNull()][AllowEmptyString()][string]$Log = '',
        [AllowNull()]$Finding,
        [AllowNull()]$Port,
        [AllowNull()][AllowEmptyString()][string]$Url = ''
    )

    $laneValue = Get-MeasurementVocabularyValue -Field 'Lane of a Timed Event' -Value $Lane -Permitted @($script:TimedEventMarks.Keys)
    $marks = $script:TimedEventMarks[$laneValue]
    $markValue = @($marks | Where-Object { $_ -eq $Mark } | Select-Object -First 1)
    if (-not $markValue.Count) {
        throw ("'{0}' is no Mark of the Lane {1}. Permitted: {2}." -f $Mark, $laneValue, ($marks -join ', '))
    }
    $markValue = $markValue[0]

    # A time or nothing, and nothing only where a log reaches back to nothing. Text that is
    # no time is refused rather than placed at a guess.
    $at = $null
    if ($null -ne $Time -and "$Time".Trim()) {
        $at = ConvertTo-DataTime $Time
        if ($null -eq $at) { throw ("'{0}' is not a time. A Timed Event is placed by its time." -f $Time) }
    }
    if ($null -eq $at -and -not ($laneValue -eq 'Log' -and $markValue -eq 'Unread')) {
        throw ("A Timed Event {0} {1} needs a time: the timeline cannot place it without one." -f $laneValue, $markValue)
    }

    if ($laneValue -eq 'Server' -and -not "$Subject".Trim() -and -not "$Address".Trim()) {
        throw 'A Timed Event of a Server names the Server, by its name or its address: it has no lane to be drawn in otherwise.'
    }
    if ($laneValue -eq 'Log' -and -not "$Log".Trim()) {
        throw "A log's reach names the log it is about."
    }
    $portValue = $null
    if ($null -ne $Port -and "$Port".Trim()) {
        $portValue = ConvertTo-Number $Port
        if ($null -eq $portValue -or $portValue -lt 1 -or $portValue -gt 65535 -or $portValue -ne [math]::Floor($portValue)) { throw ("'{0}' is not a port." -f $Port) }
        $portValue = [int]$portValue
    }
    # A URL as the Map keys a Service by it: absolute, with a host.
    $urlValue = "$Url".Trim()
    $uri = $null
    if ($urlValue -and -not ([uri]::TryCreate($urlValue, [UriKind]::Absolute, [ref]$uri) -and $uri.Host)) {
        throw ("'{0}' is not an absolute URL." -f $Url)
    }

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.TimedEvent'
        Time       = $at
        Lane       = $laneValue
        Mark       = $markValue
        Subject    = "$Subject".Trim()
        Address    = "$Address".Trim()
        Detail     = "$Detail".Trim()
        Log        = "$Log".Trim()
        Finding    = ConvertTo-FindingId -Finding $Finding -Of 'a Timed Event'
        # Stamped on by the Run, as on a Finding. See Set-CheckOrigin.
        CheckName  = ''
        App        = ''
        Port       = $portValue
        Url        = $urlValue
    }
}

function ConvertTo-LogCoverageTimedEvent {
    <#
    .SYNOPSIS
        How far back each log reaches, as the timeline marks it: one Log Reaches per log
        that was read and said, one Log Unread per log this Run's rights were refused. Pure.
    .DESCRIPTION
        A log overwrites its oldest records, so one that reaches back a week says nothing of
        the three weeks before; and a log that was not read says nothing at all. An empty
        stretch on the timeline is otherwise read as a quiet one.
 
        A log that would not say how far it reaches - an empty one - is left out: nothing
        says whether it has been cleared or nothing ever happened.
    .PARAMETER Coverage
        { Log, Oldest } per log, as the NetworkDisruption and Logon Gatherers return it.
    .PARAMETER Unreadable
        The logs this Run's rights were refused, by name.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Coverage, [AllowNull()][AllowEmptyCollection()]$Unreadable)

    foreach ($entry in @($Coverage | Where-Object { $_ })) {
        $log    = "$(Get-DataProperty $entry 'Log')".Trim()
        $oldest = ConvertTo-DataTime (Get-DataProperty $entry 'Oldest')
        if (-not $log -or $null -eq $oldest) { continue }
        New-TimedEvent -Time $oldest -Lane Log -Mark Reaches -Log $log
    }
    foreach ($log in @($Unreadable | ForEach-Object { "$_".Trim() } | Where-Object { $_ })) {
        New-TimedEvent -Lane Log -Mark Unread -Log $log
    }
}