< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.DetectSecuritySignalsQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/DetectSecuritySignalsQueryHandler.cs
Line coverage
100%
Covered lines: 54
Uncovered lines: 0
Coverable lines: 54
Total lines: 128
Line coverage: 100%
Branch coverage
100%
Covered branches: 4
Total branches: 4
Branch coverage: 100%
Method coverage
100%
Covered methods: 2
Fully covered methods: 2
Total methods: 2
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%44100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/DetectSecuritySignalsQueryHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Domain.Entities;
 3using ArturRios.Heimdall.Query.Input;
 4using ArturRios.Heimdall.Query.Output;
 5using ArturRios.Heimdall.Shared.Messages;
 6using ArturRios.Heimdall.Shared.Retention;
 7using ArturRios.Mediator.Query.Interfaces;
 8using ArturRios.Output;
 9using Microsoft.EntityFrameworkCore;
 10
 11namespace ArturRios.Heimdall.Query.Handlers;
 12
 13/// <summary>
 14///     Handles <see cref="DetectSecuritySignalsQuery" /> (NFR-26): reads the signals the system
 15///     already produces and reports the ones worth telling somebody about.
 16/// </summary>
 17/// <remarks>
 18///     <para>
 19///         <b>Why this exists.</b> The API produced strong signals and nothing consumed any of them.
 20///         The audit trail records every refused write with its reason (NFR-09), the lockout
 21///         counters track per-account brute force, and the hash gate sheds under load — all of it
 22///         written down, none of it read. A trail nobody reads does not make anyone <em>aware</em>
 23///         of anything, and awareness is what starts GDPR Art. 33's seventy-two hours and ANPD
 24///         Resolution 15/2024's three working days.
 25///     </para>
 26///     <para>
 27///         <b>Signals are rates, not totals.</b> Every threshold is measured inside a window,
 28///         because twenty refusals over a year is somebody who forgets their password and twenty in
 29///         a quarter of an hour is somebody trying things. A total would alert on the former and
 30///         never notice the latter.
 31///     </para>
 32///     <para>
 33///         <b>What this deliberately is not.</b> It does not decide that a breach has occurred, and
 34///         it cannot: a signal is a reason to look, and the runbook is what turns looking into a
 35///         declaration. Automating the declaration would put the seventy-two-hour clock in the hands
 36///         of a threshold.
 37///     </para>
 38/// </remarks>
 939public class DetectSecuritySignalsQueryHandler(
 940    IAsyncReadOnlyRepository<AuditLog, long> auditReader,
 941    IAsyncReadOnlyRepository<Person, long> personReader,
 942    DataRetentionOptions options)
 43    : IQueryHandlerAsync<DetectSecuritySignalsQuery, SecuritySignalsOutput>
 44{
 45    /// <summary>Sustained refusals against one actor — somebody trying things they may not do.</summary>
 46    public const string RepeatedRefusals = "REPEATED_REFUSALS";
 47
 48    /// <summary>More accounts locked out at once than ordinary forgetfulness explains.</summary>
 49    public const string LockoutSpike = "LOCKOUT_SPIKE";
 50
 51    /// <summary>
 52    ///     The hash gate shedding — the process at its concurrent-derivation limit, which Threat
 53    ///     Model TH-03 describes as a load condition and an attacker would recognise as a lever.
 54    /// </summary>
 55    public const string CredentialVerificationShedding = "CREDENTIAL_VERIFICATION_SHEDDING";
 56
 57    public async Task<DataOutput<SecuritySignalsOutput?>> HandleAsync(DetectSecuritySignalsQuery query, CancellationToke
 58    {
 959        var output = DataOutput<SecuritySignalsOutput?>.New;
 960        var now = DateTime.UtcNow;
 961        var since = now - options.MonitoringWindow;
 62
 963        var signals = new List<SecuritySignal>();
 64
 65        // Refusals grouped by actor. Anonymous writes are excluded: they have no actor to group by,
 66        // and the per-IP limiter is what bounds them.
 967        var refusalsByActor = await auditReader.Query()
 968            .Where(entry => entry.CreatedAt >= since && !entry.Succeeded && entry.ActorPersonId != null)
 969            .GroupBy(entry => entry.ActorPersonId)
 970            .Select(group => new { ActorId = group.Key, Count = group.Count() })
 971            .Where(group => group.Count >= options.RefusalThreshold)
 972            .ToListAsync();
 73
 1174        signals.AddRange(refusalsByActor.Select(group => new SecuritySignal
 1175        {
 1176            Kind = RepeatedRefusals,
 1177            Description =
 1178                $"One identity had {group.Count} write refused in the last {options.MonitoringWindow}. "
 1179                + "Sustained refusals are what somebody probing for what they may do looks like.",
 1180            Count = group.Count,
 1181            ActorId = group.ActorId
 1182        }));
 83
 84        // Lockouts are counted as a population rather than per account: one locked-out person is
 85        // ordinary, and the thing worth knowing is many at once, which is what credential stuffing
 86        // against a list of addresses produces.
 987        var lockedOut = await personReader.Query()
 988            .CountAsync(person => person.LockedOutUntil != null && person.LockedOutUntil > now);
 89
 990        if (lockedOut >= options.LockoutThreshold)
 91        {
 192            signals.Add(new SecuritySignal
 193            {
 194                Kind = LockoutSpike,
 195                Description =
 196                    $"{lockedOut} accounts are locked out at once. One is ordinary; many together is "
 197                    + "what credential stuffing against a list of addresses looks like.",
 198                Count = lockedOut
 199            });
 100        }
 101
 102        // The gate's own refusals reach the trail as this canonical message, so the shedding TH-03
 103        // describes is detectable without instrumenting the gate itself.
 9104        var shedding = await auditReader.Query()
 9105            .CountAsync(entry => entry.CreatedAt >= since
 9106                                 && entry.FailureReason == AuthMessages.AuthenticationTemporarilyUnavailable);
 107
 9108        if (shedding >= options.RefusalThreshold)
 109        {
 1110            signals.Add(new SecuritySignal
 1111            {
 1112                Kind = CredentialVerificationShedding,
 1113                Description =
 1114                    $"Credential verification was shed {shedding} times in the last "
 1115                    + $"{options.MonitoringWindow}. The process is at its concurrent-derivation limit "
 1116                    + "(Threat Model TH-03), which is a load condition and also a lever.",
 1117                Count = shedding
 1118            });
 119        }
 120
 9121        return output.WithData(new SecuritySignalsOutput
 9122        {
 9123            DetectedAt = now,
 9124            Window = options.MonitoringWindow,
 9125            Signals = signals
 9126        });
 9127    }
 128}