Private/Finding.ps1

# The Finding primitive.
#
# A Finding is one judged observation about the Target Machine. Creating one returns a
# value: it records nothing and prints nothing. Collecting Findings, ordering them and
# showing them is the caller's job.

# The four permitted Severity values, worst first. The Report orders by this array.
$script:SeverityValues  = @('FAIL', 'WARN', 'INFO', 'OK')

# The areas a Finding can belong to. Closed, like Severity and Privilege, because the
# Report groups by Category: a misspelling in one Kind would otherwise add a group rather
# than fail, and a Technician would read two Storage sections and trust both. Carried over
# from the Gutcheck script, except that its 'Script' is 'Gutcheck' here - Findings about
# the Run itself outlive the script #16 deletes. A new Category needs a module release,
# which is the rule a new Kind already follows.
#
# 'User' is the one added since: the Session, the User Profile and the settings of the
# User of the Run. The Report calls it "Benutzer"; see Get-ReportCategoryTitle.
#
# 'Browser' is the next: Edge, Chrome and Firefox as the User of the Run has them. It
# stands with the applications in the Report, and is a Category of its own because a
# browser is no application one Check Definition is written for.
#
# 'Website' too: a page loaded as a browser loads it, which stands with the network in the
# Report and is neither the adapter nor the domain.
$script:CategoryValues = @(
    'System', 'Power', 'CPU', 'Memory', 'Storage', 'Network', 'Domain',
    'Stability', 'Security', 'Updates', 'Startup', 'Integrity',
    'Apps', 'Browser', 'Access', 'User', 'Website', 'Gutcheck'
)

function New-Finding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        # The Check this Finding came from, named as the Technician recognises it.
        [Parameter(Mandatory)][string]$Check,

        # The area of the machine the Finding belongs to; the Report groups by it.
        [Parameter(Mandatory)][string]$Category,

        # What the Check observed. Rendered to a string here so no later stage has to.
        [AllowNull()][AllowEmptyString()]$Value,

        [ValidateSet('OK', 'INFO', 'WARN', 'FAIL')][string]$Severity = 'INFO',

        # What a first-level Technician should do about it. Absent on OK Findings.
        [AllowEmptyString()][string]$Hint = '',

        # What the Value means, where it does not say so itself. What happened, not what
        # to do: that is the Hint. Absent on OK Findings, like the Hint, unless KeepMeaning.
        [AllowNull()][AllowEmptyString()][string]$Meaning = '',

        # Keeps the Meaning on an OK Finding: for a Value that is in order and still needs
        # explaining, such as a connect answered by a firewall on the way. A Judge asks for
        # it only with a Meaning written for the OK case.
        [switch]$KeepMeaning,

        # The rights the data was gathered with. A Judge cannot know this, so it defaults
        # to the weaker of the two and the Part stamps the truth on with Set-FindingPrivilege.
        [ValidateSet('user', 'admin')][string]$Privilege = 'user',

        # What a Technician should read with this Finding. See New-Reference.
        [AllowNull()][AllowEmptyCollection()]$Reference,

        # What this Finding observed, by names from the list of Signals. Declared when the
        # thing was observed, not when it was looked for and not found.
        [AllowNull()][AllowEmptyCollection()][string[]]$Signal,

        # What the observation is about, where one Check makes a Finding for each of
        # several: the Server, the program, the drive.
        [AllowNull()][AllowEmptyString()][string]$Subject = '',

        # Where one Finding names several Servers: each of them with the Severity of its
        # part, so that a Measurement of a reachable Server is not drawn in the colour of an
        # unreachable one beside it. An ordered dictionary of Subject to Severity, or a list
        # of Subject and Severity as it comes back from the other Part. None is more severe
        # than the Finding, which stays as severe as its worst part. ADR-0009. A part may
        # name a Service as the Network Map keys one, fs01.contoso.example:445 or a URL,
        # beside its Server. ADR-0015.
        [AllowNull()]$Subjects,

        # How the events this Finding counts are spread in time, as the tokens of
        # Get-EventPattern: the union of those of what it lists. 'few' and 'short' say no
        # pattern and are not kept. Words are on the Subject parts and in the Value.
        [AllowNull()][AllowEmptyCollection()][string[]]$Pattern,

        # What this Finding would have observed and could not look for: rights it did not
        # have, a Check that was skipped. Its Value says why.
        [AllowNull()][AllowEmptyCollection()][string[]]$Unobserved,

        # What this Finding looked for and did not observe: no disk errors, a drive that
        # reports itself in order. Not a Signal, and no Lead forms from it. It is what a
        # Finding that did observe something refers to: "the drive says it is healthy"
        # belongs beside eleven disk errors.
        [AllowNull()][AllowEmptyCollection()][string[]]$Cleared,

        # When what this Finding observed was last seen, where it reads a period and
        # knows: the newest of the events it counted. What reads Findings together can
        # then say that a fault has stopped. Absent where a Finding is about now.
        [AllowNull()]$LastSeen,

        # What a Measurement names this Finding by, and findings.csv beside it. Made here
        # and never by a Judge; given only where a Finding is rebuilt, as the Elevated
        # Part's are, so that the Measurements that came with it still find it. ADR-0009.
        [AllowNull()][AllowEmptyString()][string]$Id
    )

    Assert-Signal -Signal $Signal
    Assert-Signal -Signal $Unobserved
    Assert-Signal -Signal $Cleared

    $severityValue = $Severity.ToUpperInvariant()

    # Validated here rather than with ValidateSet so the permitted values live in one
    # place, and so the error can say what was wrong and what was allowed.
    $categoryValue = $script:CategoryValues |
        Where-Object { $_ -eq $Category } | Select-Object -First 1
    if (-not $categoryValue) {
        throw ("'{0}' is not a Gutcheck Category. Permitted: {1}." -f $Category, ($script:CategoryValues -join ', '))
    }

    # A Hint tells a Technician what to do about a Finding, so an OK Finding has none.
    # Dropping it here lets a Judge decide Severity and Hint together and pass both
    # unconditionally, which is how the thresholds read most plainly.
    $hintValue = $Hint
    $meaningValue = "$Meaning"

    $subjectValues = @(ConvertTo-FindingSubjectSeverity -Subjects $Subjects -Severity $severityValue)
    if ($severityValue -eq 'OK') {
        $hintValue = ''
        if (-not $KeepMeaning) { $meaningValue = '' }
    }

    # A number handed in as one is written as a German reads it: "$Value" writes 5.7, the
    # Reliability index of a Report that says everything else with a decimal comma.
    $valueText = "$Value"
    if ($Value -is [double] -or $Value -is [single] -or $Value -is [decimal]) {
        $valueText = $Value.ToString([Globalization.CultureInfo]::GetCultureInfo('de-DE'))
    }

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.Finding'
        Severity   = $severityValue
        Category   = $categoryValue
        Check      = $Check
        Value      = $valueText
        Hint       = $hintValue
        Privilege  = $Privilege.ToLowerInvariant()
        # Which Check produced it, and for which application. A Judge cannot know either:
        # the Name is in the Check Definition and one Kind is named by many. The Run
        # stamps both on with Set-CheckOrigin, as it stamps Privilege.
        CheckName  = ''
        App        = ''
        # Kept on an OK Finding too: the evidence is as much its evidence.
        References = @($Reference | Where-Object { $null -ne $_ })
        Signals    = @($Signal | Where-Object { $_ } | Select-Object -Unique)
        Subject    = "$Subject"
        # Each Server a Finding names, with the Severity of its part. A view colours a
        # Measurement whose Subject is one of them by that Severity, and by the Finding's
        # where it is none of them.
        Subjects   = $subjectValues
        # How what it counts is spread in time, by token, for findings.csv and what reads
        # Findings together. A Technician reads the words, never these.
        Patterns   = @(ConvertTo-FindingPattern -Pattern $Pattern)
        Meaning    = $meaningValue
        Unobserved = @($Unobserved | Where-Object { $_ } | Select-Object -Unique)
        Cleared    = @($Cleared | Where-Object { $_ } | Select-Object -Unique)
        LastSeen   = $(if ($LastSeen -is [datetime]) { $LastSeen } else { $null })
        # Twelve hex digits of a new GUID: unique among the few hundred Findings of a Run,
        # across both Parts, and short enough to read in a sheet.
        Id         = $(if ("$Id".Trim()) { "$Id".Trim() } else { [guid]::NewGuid().ToString('N').Substring(0, 12) })
    }
}

function ConvertTo-FindingSubjectSeverity {
    <#
    .SYNOPSIS
        The Subjects a Finding names, each with its Severity, as the Finding carries them;
        a throw where the Judge is written for one that is no Severity, has no name, is
        named twice - one Server, Service or Namespace, by Test-SameSubject - or is more severe
        than the Finding. Pure.
    .DESCRIPTION
        Each part also carries how the events counted for it are spread in time: Patterns,
        the tokens of Get-EventPattern, and PatternText, its sentence. Given as the value of
        an ordered dictionary (an object with Severity, Patterns and PatternText in place of
        the Severity alone) or on a list item; absent, they are none and ''.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Subjects, [Parameter(Mandatory)][string]$Severity)

    if ($null -eq $Subjects) { return }
    # A part's value: a Severity alone, or an object that carries one with its pattern.
    $part = {
        param($Name, $Value)
        $read = {
            param($Key)
            if ($Value -is [System.Collections.IDictionary]) { if ($Value.Contains($Key)) { $Value[$Key] } }
            else { Get-DataProperty $Value $Key }
        }
        if ($null -eq $Value -or $Value -is [string] -or $Value -is [System.ValueType]) {
            return [pscustomobject]@{ Subject = "$Name"; Severity = "$Value"; Patterns = @(); PatternText = '' }
        }
        [pscustomobject]@{
            Subject     = "$Name"
            Severity    = "$(& $read 'Severity')"
            Patterns    = @(ConvertTo-FindingPattern -Pattern @(& $read 'Patterns' | ForEach-Object { "$_" }))
            PatternText = "$(& $read 'PatternText')".Trim()
        }
    }
    $pairs = if ($Subjects -is [System.Collections.IDictionary]) {
        @($Subjects.Keys | ForEach-Object { & $part $_ $Subjects[$_] })
    }
    else {
        @($Subjects | Where-Object { $null -ne $_ } | ForEach-Object {
            $item = $_
            $name = if ($item -is [System.Collections.IDictionary]) { $item['Subject'] } else { Get-DataProperty $item 'Subject' }
            & $part $name $item })
    }

    $rank = { param($s) [array]::IndexOf($script:SeverityValues, $s) }
    # The names given so far. One Server is one name by Test-SameServer, however each part
    # wrote it: fs01 and fs01.contoso.example would be one Server in two colours. A Service
    # is a part of its own beside its Server, and one Service is one name as well
    # (Test-SameSubject): FS01:445 and fs01.contoso.example:445. ADR-0015. A Namespace, by its
    # root, is a part of its own beside the Server the root is on (#172).
    $seen = New-Object 'System.Collections.Generic.List[string]'
    foreach ($pair in $pairs) {
        $name = $pair.Subject.Trim()
        $value = $pair.Severity.Trim().ToUpperInvariant()
        if (-not $name) { throw 'A Subject of a Finding needs a name: the Measurement it colours is found by it.' }
        if ($value -notin $script:SeverityValues) {
            throw ("'{0}' is not a Severity, for {1}. Permitted: {2}." -f $pair.Severity, $name, ($script:SeverityValues -join ', '))
        }
        $twice = @($seen | Where-Object { Test-SameSubject -Name $_ -Other $name } | Select-Object -First 1)
        if ($twice.Count) {
            throw ("'{0}' and '{1}' are one {2}, named twice among the Subjects of one Finding." -f $twice[0], $name, (Split-Subject -Name $name).Kind)
        }
        # Lower in the list is milder: FAIL is first.
        if ((& $rank $value) -lt (& $rank $Severity)) {
            throw ("{0} is {1}, more severe than its Finding ({2}): a colour no Finding says." -f $name, $value, $Severity)
        }
        $seen.Add($name)
        [pscustomobject]@{ Subject = $name; Severity = $value; Patterns = @($pair.Patterns); PatternText = $pair.PatternText }
    }
}

function ConvertTo-FindingPattern {
    <#
    .SYNOPSIS
        The pattern tokens a Finding or a Subject part carries: each once, in the order
        given, without 'few' and 'short', which say that there is none. Pure.
    #>

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

    @($Pattern | ForEach-Object { "$_".Trim() } | Where-Object { $_ -and $_ -notin @('few', 'short') } | Select-Object -Unique)
}

function Format-FindingSpan {
    <#
    .SYNOPSIS
        From when to when something a Finding counts happened, as an item of a list says it
        behind its count: "10.10. 10:24 bis 15.10. 03:11", "14.10. 22:12 bis 22:38", "am
        13.10. 07:00". Nothing where a time is not known. Pure.
    .DESCRIPTION
        51 times in one half hour and 51 times over the month are not the same fault, and
        the count alone says neither. The Report shows it as a column of its own beside the
        count (Split-ReportTally).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$First, [AllowNull()]$Last)

    if ($First -isnot [datetime] -or $Last -isnot [datetime]) { return '' }
    if ($First.ToString('yyyyMMddHHmm') -eq $Last.ToString('yyyyMMddHHmm')) { return (Get-Text 'Value.Span.Once') -f $First }
    if ($First.Date -eq $Last.Date) { return (Get-Text 'Value.Span.Day') -f $First, $Last }
    (Get-Text 'Value.Span.Days') -f $First, $Last
}

function Format-FindingCount {
    <#
    .SYNOPSIS
        What the rows name, with how often each and from when to when, the most frequent
        first, as the items of a list: "Daten 2x, 01.10. 08:00 bis 03.10. 10:30; Archiv
        1x, am 02.10. 09:00". Pure.
    .DESCRIPTION
        Every list in a Finding that counts events is written by this, so that each says
        from when to when as the others do (Format-FindingSpan), and the Report shows it as
        a column beside the count.
    .PARAMETER By
        What a row names: its kind in words, its cause, the share, the printer. A row that
        names nothing is not counted.
    .PARAMETER First
        How many to name. The others are left out, and said with More where it is given.
    .PARAMETER More
        What is said of those left out, "und {0} weitere".
    .PARAMETER TimeProperty
        Where a row keeps when it happened.
    .PARAMETER Period
        The period the events were read in, as New-EventPatternPeriod makes it. Given, each
        item also says how its events are spread in time (Get-EventPattern), behind its span.
    .PARAMETER Token
        Where the tokens of the patterns said are gathered, for New-Finding -Pattern.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Row,
        [Parameter(Mandatory)][scriptblock]$By,
        [int]$First = [int]::MaxValue,
        [string]$More = '',
        [string]$TimeProperty = 'Time',
        [AllowNull()]$Period,
        [AllowNull()][System.Collections.Generic.List[string]]$Token
    )

    $named  = @(@($Row | Where-Object { $_ }) | ForEach-Object { [pscustomobject]@{ Name = "$(& $By $_)"; Time = ConvertTo-DataTime (Get-DataProperty $_ $TimeProperty) } } |
        Where-Object { $_.Name })
    $groups = @($named | Group-Object Name | Sort-Object @{ Expression = 'Count'; Descending = $true }, Name)
    if (-not $groups.Count) { return '' }
    $items = @($groups | Select-Object -First $First | ForEach-Object {
        $times = @($_.Group | ForEach-Object { $_.Time } | Where-Object { $null -ne $_ } | Sort-Object)
        $item  = '{0} {1}x' -f $_.Name, $_.Count
        if ($times.Count) { $item += ', ' + (Format-FindingSpan -First $times[0] -Last $times[-1]) }
        if ($null -ne $Period) {
            $pattern = Get-EventPattern -Time $times -CoveredFrom $Period.CoveredFrom -RunAt $Period.RunAt -Days $Period.Days -Threshold $Period.Threshold
            if ($null -ne $Token) { foreach ($one in @($pattern.Tokens | Where-Object { $_ -notin 'few', 'short' })) { $Token.Add($one) } }
            $item = Format-EventPatternItem -Item $item -Pattern $pattern
        }
        $item })
    if ($More -and $groups.Count -gt $First) { $items += $More -f ($groups.Count - $First) }
    $items -join '; '
}

function New-Reference {
    <#
    .SYNOPSIS
        A pointer from a Finding to something a Technician should read with it.
    .DESCRIPTION
        Declared on the Finding and not written into its Hint: "see the list below" sent a
        Technician through fifty folds looking for a list that was called something else,
        and a title quoted in a sentence breaks without a sound when the title changes.
 
        A Section is named by the key of its title. The Reference carries the title as it
        reads, because that is what the Report finds the Section by; a key that does not
        exist throws here, where a Judge is written, and not on a Customer's machine.
    .PARAMETER Argument
        What a title that takes a name or a number is filled in with.
    .PARAMETER Signal
        Refers to the other Findings that carry this Signal. A Finding has no name of its
        own to be referred to by: its Check is wording, and one Check makes many Findings.
    .PARAMETER SameSubject
        Only those about the same thing as the Finding that refers: the same Server, the
        same program.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Section')]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Section')][string]$Section,
        [Parameter(ParameterSetName = 'Section')][AllowNull()][object[]]$Argument,

        [Parameter(Mandatory, ParameterSetName = 'Signal')][string]$Signal,
        [Parameter(ParameterSetName = 'Signal')][switch]$SameSubject
    )

    if ($PSCmdlet.ParameterSetName -eq 'Signal') {
        Assert-Signal -Signal $Signal
        return [pscustomobject]@{ Target = 'Signal'; Signal = $Signal; SameSubject = [bool]$SameSubject }
    }

    $title = Get-Text $Section
    if ($Argument) { $title = $title -f $Argument }
    [pscustomobject]@{ Target = 'Section'; Title = $title }
}

function Set-CheckOrigin {
    <#
    .SYNOPSIS
        Stamps onto a Finding or a Section which Check produced it, and for which application.
    .DESCRIPTION
        "Abstuerze / .NET-Fehler / Haenger: 0 / 2 / 0" does not say whose crashes, and five
        Sections titled "Laufzeitmessungen (Server)" do not say whose Servers. The Check
        Definition knows: its Name, and for an application's Check the application its
        parameters name. A Report can only say what it is talking about if every Finding
        and Section carries that.
 
        Works on whatever it is handed, because what comes back from the Elevated Part is a
        property bag and not the object this module built.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(ValueFromPipeline)]$InputObject,
        [AllowNull()][AllowEmptyString()][string]$CheckName,
        [AllowNull()][AllowEmptyString()][string]$App,

        # The Kind of the Check: the Report groups the evidence by it, and gathers the
        # Findings of the Kinds about authentication under one entry.
        [AllowNull()][AllowEmptyString()][string]$Kind
    )
    process {
        if ($null -eq $InputObject) { return }
        # A Check whose Definition names no application can still make Findings about one:
        # the Website Check's sites each name their own. What the Judge put there stays.
        $application = "$App"
        if (-not $application) { $application = "$(Get-DataProperty $InputObject 'App')" }
        $InputObject | Add-Member -NotePropertyName 'CheckName' -NotePropertyValue "$CheckName" -Force
        $InputObject | Add-Member -NotePropertyName 'App'       -NotePropertyValue $application -Force
        if ($PSBoundParameters.ContainsKey('Kind')) {
            $InputObject | Add-Member -NotePropertyName 'Kind' -NotePropertyValue "$Kind" -Force
        }
        $InputObject
    }
}

function New-UnavailableFinding {
    <#
    .SYNOPSIS
        The Finding a Judge returns when the data it needed is not there.
    .DESCRIPTION
        A counter that would not load, a device that would not answer, an empty collection:
        a real machine produces all three, and none of them is a clean result. Every Judge
        says so the same way, so a Technician learns one phrase rather than one per Check.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$Check,
        [Parameter(Mandatory)][string]$Category,
        [AllowEmptyString()][string]$Hint = ''
    )

    New-Finding -Category $Category -Check $Check -Value (Get-Text 'Value.Shared.NotAvailable') -Severity INFO -Hint $Hint
}

function Set-FindingPrivilege {
    <#
    .SYNOPSIS
        Stamps the Privilege a Part gathered with onto the Findings it produced.
    .DESCRIPTION
        Judges are pure and cannot know what rights their data was gathered with, so a Part
        stamps its own Privilege onto everything it collected. This is the single place
        Privilege is assigned, on both the Main and the Elevated Part.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]$Finding,
        [Parameter(Mandatory)][ValidateSet('user', 'admin')][string]$Privilege
    )
    process {
        $Finding.Privilege = $Privilege.ToLowerInvariant()
        $Finding
    }
}

function Get-CurrentPrivilege {
    <#
    .SYNOPSIS
        The Privilege this process gathers data with.
    .DESCRIPTION
        Not the same question as which Part is running: a Main Part that the Technician
        started elevated carries admin Privilege.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
    if (([Security.Principal.WindowsPrincipal]$identity).IsInRole(
            [Security.Principal.WindowsBuiltInRole]::Administrator)) {
        return 'admin'
    }
    'user'
}