| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Query.Input; |
| | | 4 | | using ArturRios.Heimdall.Query.Output; |
| | | 5 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 6 | | using ArturRios.Heimdall.Shared.Retention; |
| | | 7 | | using ArturRios.Mediator.Query.Interfaces; |
| | | 8 | | using ArturRios.Output; |
| | | 9 | | using Microsoft.EntityFrameworkCore; |
| | | 10 | | |
| | | 11 | | namespace 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> |
| | 9 | 39 | | public class DetectSecuritySignalsQueryHandler( |
| | 9 | 40 | | IAsyncReadOnlyRepository<AuditLog, long> auditReader, |
| | 9 | 41 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 9 | 42 | | 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 | | { |
| | 9 | 59 | | var output = DataOutput<SecuritySignalsOutput?>.New; |
| | 9 | 60 | | var now = DateTime.UtcNow; |
| | 9 | 61 | | var since = now - options.MonitoringWindow; |
| | | 62 | | |
| | 9 | 63 | | 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. |
| | 9 | 67 | | var refusalsByActor = await auditReader.Query() |
| | 9 | 68 | | .Where(entry => entry.CreatedAt >= since && !entry.Succeeded && entry.ActorPersonId != null) |
| | 9 | 69 | | .GroupBy(entry => entry.ActorPersonId) |
| | 9 | 70 | | .Select(group => new { ActorId = group.Key, Count = group.Count() }) |
| | 9 | 71 | | .Where(group => group.Count >= options.RefusalThreshold) |
| | 9 | 72 | | .ToListAsync(); |
| | | 73 | | |
| | 11 | 74 | | signals.AddRange(refusalsByActor.Select(group => new SecuritySignal |
| | 11 | 75 | | { |
| | 11 | 76 | | Kind = RepeatedRefusals, |
| | 11 | 77 | | Description = |
| | 11 | 78 | | $"One identity had {group.Count} write refused in the last {options.MonitoringWindow}. " |
| | 11 | 79 | | + "Sustained refusals are what somebody probing for what they may do looks like.", |
| | 11 | 80 | | Count = group.Count, |
| | 11 | 81 | | ActorId = group.ActorId |
| | 11 | 82 | | })); |
| | | 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. |
| | 9 | 87 | | var lockedOut = await personReader.Query() |
| | 9 | 88 | | .CountAsync(person => person.LockedOutUntil != null && person.LockedOutUntil > now); |
| | | 89 | | |
| | 9 | 90 | | if (lockedOut >= options.LockoutThreshold) |
| | | 91 | | { |
| | 1 | 92 | | signals.Add(new SecuritySignal |
| | 1 | 93 | | { |
| | 1 | 94 | | Kind = LockoutSpike, |
| | 1 | 95 | | Description = |
| | 1 | 96 | | $"{lockedOut} accounts are locked out at once. One is ordinary; many together is " |
| | 1 | 97 | | + "what credential stuffing against a list of addresses looks like.", |
| | 1 | 98 | | Count = lockedOut |
| | 1 | 99 | | }); |
| | | 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. |
| | 9 | 104 | | var shedding = await auditReader.Query() |
| | 9 | 105 | | .CountAsync(entry => entry.CreatedAt >= since |
| | 9 | 106 | | && entry.FailureReason == AuthMessages.AuthenticationTemporarilyUnavailable); |
| | | 107 | | |
| | 9 | 108 | | if (shedding >= options.RefusalThreshold) |
| | | 109 | | { |
| | 1 | 110 | | signals.Add(new SecuritySignal |
| | 1 | 111 | | { |
| | 1 | 112 | | Kind = CredentialVerificationShedding, |
| | 1 | 113 | | Description = |
| | 1 | 114 | | $"Credential verification was shed {shedding} times in the last " |
| | 1 | 115 | | + $"{options.MonitoringWindow}. The process is at its concurrent-derivation limit " |
| | 1 | 116 | | + "(Threat Model TH-03), which is a load condition and also a lever.", |
| | 1 | 117 | | Count = shedding |
| | 1 | 118 | | }); |
| | | 119 | | } |
| | | 120 | | |
| | 9 | 121 | | return output.WithData(new SecuritySignalsOutput |
| | 9 | 122 | | { |
| | 9 | 123 | | DetectedAt = now, |
| | 9 | 124 | | Window = options.MonitoringWindow, |
| | 9 | 125 | | Signals = signals |
| | 9 | 126 | | }); |
| | 9 | 127 | | } |
| | | 128 | | } |