From babf3f9f8fa2e2d62e4b26d71127e092f9d4e764 Mon Sep 17 00:00:00 2001 From: jedis <32639948+jedis00@users.noreply.github.com> Date: Tue, 26 May 2026 18:49:22 +0000 Subject: [PATCH] fix(threats): correct RFC1918 source IP attribution and add filter categories GeoEnrichmentService was redirecting MaxMind lookups to the destination IP for RFC1918 sources but writing the result back onto the source geo fields. ThreatRepository.GetTopSourcesAsync then surfaced destination ASNs under source IP labels, causing private IPs to appear as Google, NextDNS, Cloudflare, etc. in dashboards and audit PDFs. Fix splits source/dest geo on ThreatEvent and adds a GeoEnriched flag. Source enrichment writes to source fields; dest enrichment writes to dest fields. Private/loopback/link-local sources no longer receive source-side enrichment. CrowdSec enrichment short-circuits private IPs at the service entry point via a NotApplicable outcome. Adds ThreatFilterCategory enum (Noise/Infrastructure/TrustedUser) and Category/Label/IsSystem columns on ThreatNoiseFilters with supporting indexes. EnsureSelfInfrastructureFilterAsync auto-detects the optimizer's primary IPv4 at startup and registers a system Infrastructure filter so the optimizer's own scanning traffic surfaces in a categorized sub-table rather than the main Top Sources. On IP change the old entry is demoted to non-system and disabled (kept for audit history); if the prior IP returns, its entry is re-promoted. AuditService.BuildThreatSummaryAsync applies Noise-only filtering to Top Sources (Infrastructure/TrustedUser rows surface in their own sub-tables) and reads three new configurable display limits from settings: threats.top_sources_main_limit, threats.top_sources_category_limit, threats.sources_by_category_query_limit. ThreatDashboard adds a Category column with badges (purple Infrastructure, teal TrustedUser); Settings exposes the three limits as numeric inputs. Migration 20260526120000_AddDestGeoAndFilterCategory adds the new columns, indexes, and a backfill that sets GeoEnriched=1 for existing enriched rows then clears it (and nulls source geo) for RFC1918/loopback/link-local source rows so the runtime backfill loop re-attributes them under corrected logic. Tests: 5 new files covering the private-IP guards, category persistence and Demote/Promote lifecycle, CrowdSec NotApplicable behavior, and the GetTopSourcesAsync regression. All 190 Threats and 128 Storage tests pass. --- .../MarkdownReportGenerator.cs | 42 +- .../PdfReportGenerator.cs | 100 +- src/NetworkOptimizer.Reports/ReportData.cs | 27 + ...00_AddDestGeoAndFilterCategory.Designer.cs | 2479 +++++++++++++++++ ...60526120000_AddDestGeoAndFilterCategory.cs | 154 + .../NetworkOptimizerDbContextModelSnapshot.cs | 34 + .../Models/NetworkOptimizerDbContext.cs | 3 + .../Repositories/ThreatRepository.cs | 135 +- .../CrowdSec/CrowdSecClient.cs | 2 + .../CrowdSec/CrowdSecEnrichmentService.cs | 10 + .../Enrichment/GeoEnrichmentService.cs | 56 +- .../Interfaces/IThreatRepository.cs | 36 + .../Models/ThreatEvent.cs | 22 +- .../Models/ThreatFilterCategory.cs | 30 + .../Models/ThreatNoiseFilter.cs | 19 + .../ThreatCollectionService.cs | 140 + .../Components/Pages/Settings.razor | 50 + .../Components/Pages/ThreatDashboard.razor | 75 +- .../Services/AuditService.cs | 59 +- .../Services/ThreatDashboardService.cs | 48 +- .../ThreatNoiseFilterPersistenceTests.cs | 120 + .../ThreatRepositoryCategoryTests.cs | 286 ++ .../CrowdSecEnrichmentServiceTests.cs | 67 + .../GeoEnrichmentServiceTests.cs | 99 + .../ThreatNoiseFilterTests.cs | 70 + 25 files changed, 4122 insertions(+), 41 deletions(-) create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.Designer.cs create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.cs create mode 100644 src/NetworkOptimizer.Threats/Models/ThreatFilterCategory.cs create mode 100644 tests/NetworkOptimizer.Storage.Tests/ThreatNoiseFilterPersistenceTests.cs create mode 100644 tests/NetworkOptimizer.Storage.Tests/ThreatRepositoryCategoryTests.cs create mode 100644 tests/NetworkOptimizer.Threats.Tests/CrowdSecEnrichmentServiceTests.cs create mode 100644 tests/NetworkOptimizer.Threats.Tests/GeoEnrichmentServiceTests.cs create mode 100644 tests/NetworkOptimizer.Threats.Tests/ThreatNoiseFilterTests.cs diff --git a/src/NetworkOptimizer.Reports/MarkdownReportGenerator.cs b/src/NetworkOptimizer.Reports/MarkdownReportGenerator.cs index 6afbafdb1..aea2e615b 100644 --- a/src/NetworkOptimizer.Reports/MarkdownReportGenerator.cs +++ b/src/NetworkOptimizer.Reports/MarkdownReportGenerator.cs @@ -317,11 +317,51 @@ private void ComposeThreatSummary(StringBuilder sb, ReportData data) sb.AppendLine(); sb.AppendLine("| IP Address | Country | ASN | Events |"); sb.AppendLine("|-----------|---------|-----|--------|"); - foreach (var source in threat.TopSources.Take(5)) + foreach (var source in threat.TopSources) { sb.AppendLine($"| {source.Ip} | {source.CountryCode ?? "-"} | {source.AsnOrg ?? "-"} | {source.EventCount:N0} |"); } sb.AppendLine(); + + // Suppressed-count footnote + if (threat.SuppressedEventCount > 0) + { + var infraCount = threat.InfrastructureSources.Sum(s => s.EventCount); + var trustedCount = threat.TrustedUserSources.Sum(s => s.EventCount); + var parts = new List(); + if (infraCount > 0) parts.Add($"{infraCount:N0} from known infrastructure"); + if (trustedCount > 0) parts.Add($"{trustedCount:N0} from trusted user devices"); + sb.AppendLine($"*Excludes {string.Join(" and ", parts)} (see categorized tables below).*"); + sb.AppendLine(); + } + } + + // Known Infrastructure Activity + if (threat.InfrastructureSources.Any()) + { + sb.AppendLine("### Known Infrastructure Activity"); + sb.AppendLine(); + sb.AppendLine("| IP Address | Label | Country | Events |"); + sb.AppendLine("|-----------|-------|---------|--------|"); + foreach (var source in threat.InfrastructureSources) + { + sb.AppendLine($"| {source.Ip} | {source.Label ?? "-"} | {source.CountryCode ?? "-"} | {source.EventCount:N0} |"); + } + sb.AppendLine(); + } + + // Trusted User Activity + if (threat.TrustedUserSources.Any()) + { + sb.AppendLine("### Trusted User Activity"); + sb.AppendLine(); + sb.AppendLine("| IP Address | Label | Country | Events |"); + sb.AppendLine("|-----------|-------|---------|--------|"); + foreach (var source in threat.TrustedUserSources) + { + sb.AppendLine($"| {source.Ip} | {source.Label ?? "-"} | {source.CountryCode ?? "-"} | {source.EventCount:N0} |"); + } + sb.AppendLine(); } // Exposed services diff --git a/src/NetworkOptimizer.Reports/PdfReportGenerator.cs b/src/NetworkOptimizer.Reports/PdfReportGenerator.cs index 83482f91b..3dcd250b9 100644 --- a/src/NetworkOptimizer.Reports/PdfReportGenerator.cs +++ b/src/NetworkOptimizer.Reports/PdfReportGenerator.cs @@ -1205,7 +1205,7 @@ private void ComposeThreatSummary(IContainer container, ReportData data) .Text("Events").Bold().FontSize(8); }); - foreach (var source in threat.TopSources.Take(5)) + foreach (var source in threat.TopSources) { table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4) .Text(source.Ip).FontSize(8); @@ -1217,6 +1217,48 @@ private void ComposeThreatSummary(IContainer container, ReportData data) .Text(source.EventCount.ToString("N0")).FontSize(8); } }); + + // Suppressed-count footnote: tells the reader the table above excludes + // events from internal infrastructure and trusted user devices, with + // the actual numbers shown in the sub-tables that follow. + if (threat.SuppressedEventCount > 0) + { + var infraCount = threat.InfrastructureSources.Sum(s => s.EventCount); + var trustedCount = threat.TrustedUserSources.Sum(s => s.EventCount); + var parts = new List(); + if (infraCount > 0) parts.Add($"{infraCount:N0} from known infrastructure"); + if (trustedCount > 0) parts.Add($"{trustedCount:N0} from trusted user devices"); + var footnote = $"Excludes {string.Join(" and ", parts)} (see categorized tables below)."; + + column.Item() + .PaddingTop(4) + .Text(footnote) + .FontSize(8) + .Italic() + .FontColor(Colors.Grey.Medium); + } + } + + // Known Infrastructure Activity + if (threat.InfrastructureSources.Any()) + { + ComposeCategorizedSourceTable( + column, + "Known Infrastructure Activity", + threat.InfrastructureSources, + primaryColor, + lightGray); + } + + // Trusted User Activity + if (threat.TrustedUserSources.Any()) + { + ComposeCategorizedSourceTable( + column, + "Trusted User Activity", + threat.TrustedUserSources, + primaryColor, + lightGray); } // Exposed services @@ -1273,6 +1315,62 @@ private void ComposeThreatSummary(IContainer container, ReportData data) }); } + /// + /// Renders a small sub-table beneath the main "Top Threat Sources" table for + /// a categorized group (Known Infrastructure Activity or Trusted User Activity). + /// Adds a Label column so the reader can see what each source actually is. + /// + private void ComposeCategorizedSourceTable( + ColumnDescriptor column, + string title, + List sources, + string primaryColor, + string lightGray) + { + column.Item() + .PaddingTop(10) + .PaddingBottom(4) + .Text(title) + .FontSize(10) + .Bold() + .FontColor(primaryColor); + + column.Item().Table(table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(2f); // IP + columns.RelativeColumn(2.5f); // Label + columns.RelativeColumn(1f); // Country + columns.RelativeColumn(1f); // Events + }); + + table.Header(header => + { + header.Cell().Background(lightGray).Padding(4) + .Text("IP Address").Bold().FontSize(8); + header.Cell().Background(lightGray).Padding(4) + .Text("Label").Bold().FontSize(8); + header.Cell().Background(lightGray).Padding(4) + .Text("Country").Bold().FontSize(8); + header.Cell().Background(lightGray).Padding(4) + .Text("Events").Bold().FontSize(8); + }); + + foreach (var source in sources) + { + table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4) + .Text(source.Ip).FontSize(8); + table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4) + .Text(source.Label ?? "-").FontSize(8); + table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4) + .Text(source.CountryCode ?? "-").FontSize(8); + table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4) + .Text(source.EventCount.ToString("N0")).FontSize(8); + } + }); + } + private void ComposePortSecuritySummary(IContainer container, ReportData data) { var primaryColor = GetColor(_branding.Colors.Primary); diff --git a/src/NetworkOptimizer.Reports/ReportData.cs b/src/NetworkOptimizer.Reports/ReportData.cs index be9de2553..34a4dd039 100644 --- a/src/NetworkOptimizer.Reports/ReportData.cs +++ b/src/NetworkOptimizer.Reports/ReportData.cs @@ -559,6 +559,26 @@ public class ThreatSummaryData public Dictionary ByKillChain { get; set; } = new(); public List TopSources { get; set; } = new(); public List ExposedServices { get; set; } = new(); + + /// + /// Source IPs matching enabled Infrastructure-category noise filters. Shown + /// in a separate "Known Infrastructure Activity" sub-table so the user can + /// see what was excluded from the main Top Threat Sources table. + /// + public List InfrastructureSources { get; set; } = new(); + + /// + /// Source IPs matching enabled TrustedUser-category noise filters. Shown + /// in a separate "Trusted User Activity" sub-table. + /// + public List TrustedUserSources { get; set; } = new(); + + /// + /// Aggregate event count for events suppressed from the top threat sources + /// table because they matched Infrastructure or TrustedUser filters. Used + /// for the "X events suppressed" header line. + /// + public int SuppressedEventCount { get; set; } } public class ThreatSourceEntry @@ -567,6 +587,13 @@ public class ThreatSourceEntry public string? CountryCode { get; set; } public string? AsnOrg { get; set; } public int EventCount { get; set; } + + /// + /// Human-readable label from the matching noise filter for categorized + /// sub-tables (e.g., "Network Optimizer (self)"). Null for the main + /// Top Threat Sources table. + /// + public string? Label { get; set; } } public class ExposedServiceEntry diff --git a/src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.Designer.cs new file mode 100644 index 000000000..7c0761c99 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.Designer.cs @@ -0,0 +1,2479 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + [DbContext(typeof(NetworkOptimizerDbContext))] + [Migration("20260526120000_AddDestGeoAndFilterCategory")] + partial class AddDestGeoAndFilterCategory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AuditDate") + .HasColumnType("TEXT"); + + b.Property("ComplianceScore") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FailedChecks") + .HasColumnType("INTEGER"); + + b.Property("FindingsJson") + .HasColumnType("TEXT"); + + b.Property("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmBaseline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgBytesIn") + .HasColumnType("INTEGER"); + + b.Property("AvgBytesOut") + .HasColumnType("INTEGER"); + + b.Property("AvgJitter") + .HasColumnType("REAL"); + + b.Property("AvgLatency") + .HasColumnType("REAL"); + + b.Property("AvgPacketLoss") + .HasColumnType("REAL"); + + b.Property("AvgUtilization") + .HasColumnType("REAL"); + + b.Property("BaselineEnd") + .HasColumnType("TEXT"); + + b.Property("BaselineHours") + .HasColumnType("INTEGER"); + + b.Property("BaselineStart") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HourlyDataJson") + .HasColumnType("TEXT"); + + b.Property("InterfaceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxJitter") + .HasColumnType("REAL"); + + b.Property("MaxPacketLoss") + .HasColumnType("REAL"); + + b.Property("MedianBytesIn") + .HasColumnType("INTEGER"); + + b.Property("MedianBytesOut") + .HasColumnType("INTEGER"); + + b.Property("P95Latency") + .HasColumnType("REAL"); + + b.Property("P99Latency") + .HasColumnType("REAL"); + + b.Property("PeakBytesIn") + .HasColumnType("INTEGER"); + + b.Property("PeakBytesOut") + .HasColumnType("INTEGER"); + + b.Property("PeakLatency") + .HasColumnType("REAL"); + + b.Property("PeakUtilization") + .HasColumnType("REAL"); + + b.Property("RecommendedDownloadMbps") + .HasColumnType("REAL"); + + b.Property("RecommendedUploadMbps") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AgentConfiguration", b => + { + b.Property("AgentId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("AdditionalSettingsJson") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AuditEnabled") + .HasColumnType("INTEGER"); + + b.Property("AuditIntervalHours") + .HasColumnType("INTEGER"); + + b.Property("BatchSize") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("FlushIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("MetricsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SqmEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("AgentId"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("LastSeenAt"); + + b.ToTable("AgentConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ClientSignalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApChannel") + .HasColumnType("INTEGER"); + + b.Property("ApClientCount") + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("ApModel") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApRadioBand") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ApTxPower") + .HasColumnType("INTEGER"); + + b.Property("Band") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BottleneckLinkSpeedMbps") + .HasColumnType("REAL"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("ChannelWidth") + .HasColumnType("INTEGER"); + + b.Property("ClientIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("ClientMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopCount") + .HasColumnType("INTEGER"); + + b.Property("IsMlo") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MloLinksJson") + .HasColumnType("TEXT"); + + b.Property("NoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TraceHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TraceJson") + .HasColumnType("TEXT"); + + b.Property("TxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TraceHash"); + + b.HasIndex("ClientMac", "Timestamp"); + + b.ToTable("ClientSignalLogs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("FeaturesJson") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LicensedTo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxAgents") + .HasColumnType("INTEGER"); + + b.Property("MaxDevices") + .HasColumnType("INTEGER"); + + b.Property("Organization") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.GatewaySshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3Port") + .HasColumnType("INTEGER"); + + b.Property("TcMonitorPort") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DismissedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IssueKey") + .IsUnique(); + + b.ToTable("DismissedIssues", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ExternalSpeedTestServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ServerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId") + .IsUnique(); + + b.ToTable("ExternalSpeedTestServers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ModemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("ModemType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Host"); + + b.HasIndex("Enabled"); + + b.ToTable("ModemConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FastPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbLongtermBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbOrg") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbReachable") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InfluxDbUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbCheck") + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastSnmpDetection") + .HasColumnType("TEXT"); + + b.Property("LastSnmpSuccess") + .HasColumnType("TEXT"); + + b.Property("LastUpstreamDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("UpstreamDiscoveryNeedsReview") + .HasColumnType("INTEGER"); + + b.Property("MediumPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SlowPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SnmpCommunity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpDetectionState") + .HasColumnType("INTEGER"); + + b.Property("SnmpV3AuthPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpV3Username") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SnmpVersion") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanNeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MonitoringSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AutoLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AutoDiscovered") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DiscoveryMethod") + .HasColumnType("INTEGER"); + + b.Property("DiscoveredProbeMode") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PingCount") + .HasColumnType("INTEGER"); + + b.Property("PollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProbeMode") + .HasColumnType("INTEGER"); + + b.Property("PtrHostname") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("VantagePoint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TargetId") + .IsUnique(); + + b.HasIndex("TargetType"); + + b.HasIndex("WanInterface"); + + b.ToTable("MonitoringTargets", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDiscoveryContext", b => + { + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("L2NeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("NeedsReview") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("WanInterface"); + + b.ToTable("WanDiscoveryContexts", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.InterfaceNameMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IfIndex") + .HasColumnType("INTEGER"); + + b.Property("IfAlias") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IfName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsWan") + .HasColumnType("INTEGER"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("PortNumber") + .HasColumnType("INTEGER"); + + b.Property("SpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "IfName") + .IsUnique(); + + b.ToTable("InterfaceNameMaps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpstreamDiscovery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopIp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HopNumber") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTracerouteAt") + .HasColumnType("TEXT"); + + b.Property("LastValidated") + .HasColumnType("TEXT"); + + b.Property("MonitoringTargetId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AsnNumber"); + + b.HasIndex("IsActive"); + + b.HasIndex("MonitoringTargetId"); + + b.ToTable("UpstreamDiscoveries", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoredSfp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsPon") + .HasColumnType("INTEGER"); + + b.Property("IsMonitoredOnt") + .HasColumnType("INTEGER"); + + b.Property("PortName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SfpPart") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SfpVendor") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMonitoredOnt"); + + b.HasIndex("DeviceMac", "PortName") + .IsUnique(); + + b.ToTable("MonitoredSfps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OuiVendor", b => + { + b.Property("OuiPrefix") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("OuiPrefix"); + + b.ToTable("OuiVendors", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DeviceSshConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3BinaryPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Iperf3DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Iperf3ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SshPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshPrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartIperf3Server") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Host"); + + b.HasIndex("Enabled"); + + b.ToTable("DeviceSshConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Iperf3Result", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("DownloadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("DownloadBytes") + .HasColumnType("INTEGER"); + + b.Property("DownloadJitterMs") + .HasColumnType("REAL"); + + b.Property("DownloadLatencyMs") + .HasColumnType("REAL"); + + b.Property("DownloadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("PathAnalysisJson") + .HasColumnType("TEXT"); + + b.Property("PingMs") + .HasColumnType("REAL"); + + b.Property("RawDownloadJson") + .HasColumnType("TEXT"); + + b.Property("RawUploadJson") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Success") + .HasColumnType("INTEGER"); + + b.Property("TestTime") + .HasColumnType("TEXT"); + + b.Property("UploadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("UploadBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadJitterMs") + .HasColumnType("REAL"); + + b.Property("UploadLatencyMs") + .HasColumnType("REAL"); + + b.Property("UploadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExternalServerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanNetworkGroup") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WifiChannel") + .HasColumnType("INTEGER"); + + b.Property("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiRxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceHost"); + + b.HasIndex("Direction"); + + b.HasIndex("TestTime"); + + b.HasIndex("DeviceHost", "TestTime"); + + b.ToTable("Iperf3Results", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiConnectionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ControllerUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IgnoreControllerSSLErrors") + .HasColumnType("INTEGER"); + + b.Property("IsConfigured") + .HasColumnType("INTEGER"); + + b.Property("LastConnectedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RememberCredentials") + .HasColumnType("INTEGER"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiConnectionSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NominalDownloadMbps") + .HasColumnType("INTEGER"); + + b.Property("NominalUploadMbps") + .HasColumnType("INTEGER"); + + b.Property("PingHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SpeedtestEveningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestServerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanNumber") + .IsUnique(); + + b.ToTable("SqmWanConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostIp", "Port", "Protocol") + .IsUnique(); + + b.ToTable("UpnpNotes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CenterLatitude") + .HasColumnType("REAL"); + + b.Property("CenterLongitude") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Buildings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildingId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorMaterial") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FloorNumber") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.ToTable("FloorPlans", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CropJson") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorPlanId") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("RotationDeg") + .HasColumnType("REAL"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FloorPlanId"); + + b.ToTable("FloorPlanImages", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PerfTweakSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("TweakId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TweakId") + .IsUnique(); + + b.ToTable("PerfTweakSettings"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Building", "Building") + .WithMany("Floors") + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.FloorPlan", "FloorPlan") + .WithMany("Images") + .HasForeignKey("FloorPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FloorPlan"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CooldownSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestOnly") + .HasColumnType("INTEGER"); + + b.Property("EscalationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EscalationSeverity") + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("TargetDevices") + .HasColumnType("TEXT"); + + b.Property("ThresholdPercent") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.DeliveryChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelType") + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("DigestSchedule") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DeliveryChannels", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("ContextJson") + .HasColumnType("TEXT"); + + b.Property("DeliveredToChannels") + .HasColumnType("TEXT"); + + b.Property("DeliveryError") + .HasColumnType("TEXT"); + + b.Property("DeliverySucceeded") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("DeviceIp") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IncidentId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("INTEGER"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId"); + + b.HasIndex("RuleId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.HasIndex("Source", "TriggeredAt"); + + b.ToTable("AlertHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertCount") + .HasColumnType("INTEGER"); + + b.Property("CorrelationKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationKey"); + + b.HasIndex("Status"); + + b.ToTable("AlertIncidents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("Label") + .HasColumnType("TEXT"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Category", "Enabled"); + + b.HasIndex("Category", "SourceIp"); + + b.ToTable("ThreatNoiseFilters", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PatternType") + .HasColumnType("INTEGER"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("DestCountryCode") + .HasColumnType("TEXT"); + + b.Property("DestCity") + .HasColumnType("TEXT"); + + b.Property("DestAsn") + .HasColumnType("INTEGER"); + + b.Property("DestAsnOrg") + .HasColumnType("TEXT"); + + b.Property("DestLatitude") + .HasColumnType("REAL"); + + b.Property("DestLongitude") + .HasColumnType("REAL"); + + b.Property("GeoEnriched") + .HasColumnType("INTEGER"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("KillChainStage"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("PatternId"); + + b.ToTable("ThreatEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.HasOne("NetworkOptimizer.Threats.Models.ThreatPattern", "Pattern") + .WithMany("Events") + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TaskType"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DstCidrsJson") + .HasColumnType("TEXT"); + + b.Property("DstPortsJson") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Probability") + .HasColumnType("REAL"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SrcCidrsJson") + .HasColumnType("TEXT"); + + b.Property("SrcMacsJson") + .HasColumnType("TEXT"); + + b.Property("SrcPortsJson") + .HasColumnType("TEXT"); + + b.Property("TargetWanKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("WanSteerTrafficClasses"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.cs b/src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.cs new file mode 100644 index 000000000..12a0dd116 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.cs @@ -0,0 +1,154 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + /// + /// Splits source/destination geo enrichment on ThreatEvents (preventing + /// destination ASNs from appearing on source-IP groupings) and adds + /// Category/Label/IsSystem to ThreatNoiseFilters so the audit report + /// can surface Infrastructure and TrustedUser activity in separate + /// categorized sub-tables. + /// + public partial class AddDestGeoAndFilterCategory : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + // --- ThreatEvents: destination geo/ASN columns --- + migrationBuilder.AddColumn( + name: "DestCountryCode", + table: "ThreatEvents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "DestCity", + table: "ThreatEvents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "DestAsn", + table: "ThreatEvents", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "DestAsnOrg", + table: "ThreatEvents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "DestLatitude", + table: "ThreatEvents", + type: "REAL", + nullable: true); + + migrationBuilder.AddColumn( + name: "DestLongitude", + table: "ThreatEvents", + type: "REAL", + nullable: true); + + // Tracks whether geo enrichment has been attempted on a row. Lets the + // backfill loop skip events whose source is RFC1918 (which legitimately + // have null source geo and would otherwise be re-processed forever). + migrationBuilder.AddColumn( + name: "GeoEnriched", + table: "ThreatEvents", + type: "INTEGER", + nullable: false, + defaultValue: false); + + // Backfill: existing rows with non-null CountryCode or DestCountryCode have + // already been through the old enrichment path. Mark them as enriched so + // the new flag-driven backfill does not re-process them. + migrationBuilder.Sql( + "UPDATE ThreatEvents SET GeoEnriched = 1 WHERE CountryCode IS NOT NULL;"); + + // Data integrity: pre-fix RFC1918 source rows have the destination's ASN/Country + // written to their source-geo fields (the bug being fixed). Null those fields + // and clear GeoEnriched so the backfill re-runs against the corrected logic + // and the source rows end up with empty source-geo (the truthful answer). + // SQLite has no CIDR functions so we enumerate RFC1918, loopback, and link-local + // prefixes explicitly. + migrationBuilder.Sql(@" + UPDATE ThreatEvents + SET CountryCode = NULL, + City = NULL, + Asn = NULL, + AsnOrg = NULL, + Latitude = NULL, + Longitude = NULL, + GeoEnriched = 0 + WHERE SourceIp LIKE '10.%' + OR SourceIp LIKE '192.168.%' + OR SourceIp LIKE '127.%' + OR SourceIp LIKE '169.254.%' + OR SourceIp LIKE '172.16.%' OR SourceIp LIKE '172.17.%' + OR SourceIp LIKE '172.18.%' OR SourceIp LIKE '172.19.%' + OR SourceIp LIKE '172.20.%' OR SourceIp LIKE '172.21.%' + OR SourceIp LIKE '172.22.%' OR SourceIp LIKE '172.23.%' + OR SourceIp LIKE '172.24.%' OR SourceIp LIKE '172.25.%' + OR SourceIp LIKE '172.26.%' OR SourceIp LIKE '172.27.%' + OR SourceIp LIKE '172.28.%' OR SourceIp LIKE '172.29.%' + OR SourceIp LIKE '172.30.%' OR SourceIp LIKE '172.31.%';"); + + // --- ThreatNoiseFilters: category, label, system flag --- + migrationBuilder.AddColumn( + name: "Category", + table: "ThreatNoiseFilters", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Label", + table: "ThreatNoiseFilters", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsSystem", + table: "ThreatNoiseFilters", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateIndex( + name: "IX_ThreatNoiseFilters_Category_Enabled", + table: "ThreatNoiseFilters", + columns: new[] { "Category", "Enabled" }); + + migrationBuilder.CreateIndex( + name: "IX_ThreatNoiseFilters_Category_SourceIp", + table: "ThreatNoiseFilters", + columns: new[] { "Category", "SourceIp" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ThreatNoiseFilters_Category_SourceIp", + table: "ThreatNoiseFilters"); + + migrationBuilder.DropIndex( + name: "IX_ThreatNoiseFilters_Category_Enabled", + table: "ThreatNoiseFilters"); + + migrationBuilder.DropColumn(name: "IsSystem", table: "ThreatNoiseFilters"); + migrationBuilder.DropColumn(name: "Label", table: "ThreatNoiseFilters"); + migrationBuilder.DropColumn(name: "Category", table: "ThreatNoiseFilters"); + + migrationBuilder.DropColumn(name: "DestLongitude", table: "ThreatEvents"); + migrationBuilder.DropColumn(name: "DestLatitude", table: "ThreatEvents"); + migrationBuilder.DropColumn(name: "DestAsnOrg", table: "ThreatEvents"); + migrationBuilder.DropColumn(name: "DestAsn", table: "ThreatEvents"); + migrationBuilder.DropColumn(name: "DestCity", table: "ThreatEvents"); + migrationBuilder.DropColumn(name: "DestCountryCode", table: "ThreatEvents"); + migrationBuilder.DropColumn(name: "GeoEnriched", table: "ThreatEvents"); + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs b/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs index 434cc2645..74a45ac15 100644 --- a/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs +++ b/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs @@ -2045,6 +2045,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("Label") + .HasColumnType("TEXT"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + b.Property("Enabled") .HasColumnType("INTEGER"); @@ -2053,6 +2062,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("Category", "Enabled"); + + b.HasIndex("Category", "SourceIp"); + b.ToTable("ThreatNoiseFilters", (string)null); }); @@ -2170,6 +2183,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Longitude") .HasColumnType("REAL"); + b.Property("DestCountryCode") + .HasColumnType("TEXT"); + + b.Property("DestCity") + .HasColumnType("TEXT"); + + b.Property("DestAsn") + .HasColumnType("INTEGER"); + + b.Property("DestAsnOrg") + .HasColumnType("TEXT"); + + b.Property("DestLatitude") + .HasColumnType("REAL"); + + b.Property("DestLongitude") + .HasColumnType("REAL"); + + b.Property("GeoEnriched") + .HasColumnType("INTEGER"); + b.Property("KillChainStage") .HasColumnType("INTEGER"); diff --git a/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs b/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs index 4fe5be44f..288f55f1d 100644 --- a/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs +++ b/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs @@ -385,6 +385,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.ToTable("ThreatNoiseFilters"); + entity.Property(e => e.Category).HasConversion(); + entity.HasIndex(e => new { e.Category, e.Enabled }); + entity.HasIndex(e => new { e.Category, e.SourceIp }); }); // ScheduledTask configuration diff --git a/src/NetworkOptimizer.Storage/Repositories/ThreatRepository.cs b/src/NetworkOptimizer.Storage/Repositories/ThreatRepository.cs index 5b67f620f..9c16f8c73 100644 --- a/src/NetworkOptimizer.Storage/Repositories/ThreatRepository.cs +++ b/src/NetworkOptimizer.Storage/Repositories/ThreatRepository.cs @@ -310,6 +310,111 @@ public async Task> GetTopSourcesAsync(DateTime from, DateT } } + public async Task> GetSourcesByCategoryAsync(DateTime from, DateTime to, + ThreatFilterCategory category, int count = 20, CancellationToken cancellationToken = default) + { + try + { + // Load enabled filters of this category (bypasses _noiseFilters which would + // exclude them - we want to surface exactly these source IPs). + var filters = await _context.ThreatNoiseFilters + .AsNoTracking() + .Where(f => f.Enabled && f.Category == category && f.SourceIp != null) + .ToListAsync(cancellationToken); + + if (filters.Count == 0) + return new List(); + + // Partition filter source IPs into exact matches and CIDR prefixes so the + // query can OR them in a single Where clause (translates to a small set + // of OR conditions on real providers and works on the in-memory provider). + var exactIps = filters + .Where(f => f.SourceIp != null && !f.SourceIp.Contains('/')) + .Select(f => f.SourceIp!) + .Distinct() + .ToList(); + + var cidrPrefixes = filters + .Select(f => ToCidrPrefix(f.SourceIp)) + .Where(p => p != null) + .Select(p => p!) + .Distinct() + .ToList(); + + if (exactIps.Count == 0 && cidrPrefixes.Count == 0) + return new List(); + + // Build a base query that does NOT apply noise filtering (the whole point + // is to surface what was suppressed). Severity filter still applies if set. + var baseQ = _context.ThreatEvents + .AsNoTracking() + .Where(e => e.Timestamp >= from && e.Timestamp <= to); + + if (_severityFilter is { Length: > 0 }) + baseQ = baseQ.Where(e => _severityFilter.Contains(e.Severity)); + + // Build one sub-query per match shape (exact IPs in one Contains, then one + // Where per CIDR prefix) and Union them. cidrPrefixes.Any(p => e.SourceIp.StartsWith(p)) + // would be more concise but the EF Core InMemory provider used by tests cannot + // translate that lambda pattern; per-prefix Union works on both InMemory and SQL. + var queries = new List>(); + if (exactIps.Count > 0) + queries.Add(baseQ.Where(e => exactIps.Contains(e.SourceIp))); + foreach (var prefix in cidrPrefixes) + { + var p = prefix; // capture per loop iteration + queries.Add(baseQ.Where(e => e.SourceIp.StartsWith(p))); + } + + var matched = queries.Aggregate((a, b) => a.Union(b)); + + var rows = await matched + .GroupBy(e => e.SourceIp) + .Select(g => new + { + SourceIp = g.Key, + EventCount = g.Count(), + CountryCode = g.First().CountryCode, + City = g.First().City, + Asn = g.First().Asn, + AsnOrg = g.First().AsnOrg, + MaxSeverity = g.Max(e => e.Severity) + }) + .OrderByDescending(s => s.EventCount) + .Take(count) + .ToListAsync(cancellationToken); + + // Attach the filter's label to each source by re-matching the IP locally. + // Exact-match filters take precedence over CIDR matches when both apply. + return rows.Select(r => + { + var label = filters + .Where(f => f.Matches(r.SourceIp, null, null)) + .OrderBy(f => f.SourceIp != null && f.SourceIp.Contains('/') ? 1 : 0) + .Select(f => f.Label) + .FirstOrDefault(l => !string.IsNullOrEmpty(l)); + + return new SourceIpSummary + { + SourceIp = r.SourceIp, + EventCount = r.EventCount, + CountryCode = r.CountryCode, + City = r.City, + Asn = r.Asn, + AsnOrg = r.AsnOrg, + MaxSeverity = r.MaxSeverity, + Label = label, + MatchedFilterCategory = category + }; + }).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get sources by category {Category}", category); + throw; + } + } + public async Task> GetTopTargetedPortsAsync(DateTime from, DateTime to, int count = 10, CancellationToken cancellationToken = default) { @@ -698,9 +803,11 @@ public async Task BackfillGeoDataAsync(Action> enrichActi { try { - // Get events with null geo data (tracked so changes are saved) + // Pick events that have not yet been processed by the enrichment service. + // Pre-fix events (where GeoEnriched defaults to false) get a one-time pass; + // RFC1918 sources will end up with null source geo, which is correct. var events = await _context.ThreatEvents - .Where(e => e.CountryCode == null) + .Where(e => !e.GeoEnriched) .OrderByDescending(e => e.Timestamp) .Take(batchSize) .ToListAsync(cancellationToken); @@ -976,6 +1083,30 @@ await _context.ThreatNoiseFilters .ExecuteUpdateAsync(s => s.SetProperty(f => f.Enabled, enabled), cancellationToken); } + public async Task DemoteAndDisableSystemFilterAsync(int filterId, CancellationToken cancellationToken = default) + { + // Load-modify-save instead of ExecuteUpdateAsync: ExecuteUpdate is not + // supported by the EF Core InMemory provider used by the test suite. + // SQLite supports it in production but cross-provider compatibility wins + // here over the marginal efficiency of a single statement. + var filter = await _context.ThreatNoiseFilters + .FirstOrDefaultAsync(f => f.Id == filterId, cancellationToken); + if (filter == null) return; + filter.IsSystem = false; + filter.Enabled = false; + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task PromoteToSystemFilterAsync(int filterId, CancellationToken cancellationToken = default) + { + var filter = await _context.ThreatNoiseFilters + .FirstOrDefaultAsync(f => f.Id == filterId, cancellationToken); + if (filter == null) return; + filter.IsSystem = true; + filter.Enabled = true; + await _context.SaveChangesAsync(cancellationToken); + } + #endregion } diff --git a/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecClient.cs b/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecClient.cs index 7afae8df1..4bece58c9 100644 --- a/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecClient.cs +++ b/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecClient.cs @@ -13,6 +13,8 @@ public enum CrowdSecLookupOutcome QuotaExhausted, /// Burst throttle ("Too Many Requests"). Transient - caller should retry later. BurstThrottled, + /// IP is RFC1918/loopback/link-local - reputation lookup not applicable. + NotApplicable, Error } diff --git a/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecEnrichmentService.cs b/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecEnrichmentService.cs index c1aa0a6c5..82abf2875 100644 --- a/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecEnrichmentService.cs +++ b/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecEnrichmentService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.Extensions.Logging; +using NetworkOptimizer.Core.Helpers; using NetworkOptimizer.Threats.Interfaces; using NetworkOptimizer.Threats.Models; @@ -26,6 +27,9 @@ public CrowdSecEnrichmentService( /// Positive hits are cached for (default 720 = 30 days). /// Negative hits (IP not in CrowdSec DB) are cached for 24 hours to avoid wasting API calls. /// Rate-limited or errored lookups are NOT cached so the IP can be retried later. + /// RFC1918/loopback/link-local IPs short-circuit with NotApplicable before any cache + /// or API call, since CrowdSec's CTI database is for public IPs only and a private-IP + /// lookup would burn quota for no value. /// public async Task<(CrowdSecIpInfo? Info, CrowdSecLookupOutcome Outcome)> GetReputationAsync( string ipAddress, @@ -34,6 +38,12 @@ public CrowdSecEnrichmentService( int cacheTtlHours = 720, CancellationToken cancellationToken = default) { + // Centralized guard: private/loopback/link-local IPs cannot have public reputation, + // so we short-circuit before touching cache or the API. Inline guards at call sites + // remain as defense-in-depth. + if (NetworkUtilities.IsPrivateIpAddress(ipAddress)) + return (null, CrowdSecLookupOutcome.NotApplicable); + // Check cache first var cached = await repository.GetCrowdSecCacheAsync(ipAddress, cancellationToken); if (cached != null) diff --git a/src/NetworkOptimizer.Threats/Enrichment/GeoEnrichmentService.cs b/src/NetworkOptimizer.Threats/Enrichment/GeoEnrichmentService.cs index 49347eda1..f83db9313 100644 --- a/src/NetworkOptimizer.Threats/Enrichment/GeoEnrichmentService.cs +++ b/src/NetworkOptimizer.Threats/Enrichment/GeoEnrichmentService.cs @@ -145,9 +145,12 @@ public GeoInfo Enrich(string ipAddress) } /// - /// Batch-enrich threat events with geo/ASN data. - /// For flow events where the source IP is internal (RFC1918), enriches on the destination IP - /// instead, since the external endpoint is what needs geo data. + /// Batch-enrich threat events with geo/ASN data. The source IP is enriched into + /// the source fields (Asn/AsnOrg/CountryCode/...) and the destination IP is + /// enriched into the destination fields (DestAsn/DestAsnOrg/DestCountryCode/...). + /// For RFC1918 addresses the lookup returns Empty, which is the correct answer. + /// Keeping source and destination enrichment in separate fields prevents the + /// "internal source tagged with external ASN" bug in source-IP groupings. /// public void EnrichEvents(List events) { @@ -157,31 +160,36 @@ public void EnrichEvents(List events) // Cache lookups per IP within the batch var cache = new Dictionary(); - foreach (var evt in events) + GeoInfo LookupCached(string? ip) { - // For flow events with internal source, enrich on the destination IP - var enrichIp = evt.SourceIp; - if (evt.EventSource == EventSource.TrafficFlow && - !string.IsNullOrEmpty(evt.SourceIp) && - IPAddress.TryParse(evt.SourceIp, out var srcIp) && - NetworkUtilities.IsPrivateIpAddress(srcIp) && - !string.IsNullOrEmpty(evt.DestIp)) - { - enrichIp = evt.DestIp; - } - - if (!cache.TryGetValue(enrichIp, out var geo)) + if (string.IsNullOrEmpty(ip)) return GeoInfo.Empty; + if (!cache.TryGetValue(ip, out var g)) { - geo = Enrich(enrichIp); - cache[enrichIp] = geo; + g = Enrich(ip); + cache[ip] = g; } + return g; + } - evt.CountryCode = geo.CountryCode; - evt.City = geo.City; - evt.Latitude = geo.Latitude; - evt.Longitude = geo.Longitude; - evt.Asn = geo.Asn; - evt.AsnOrg = geo.AsnOrg; + foreach (var evt in events) + { + var srcGeo = LookupCached(evt.SourceIp); + evt.CountryCode = srcGeo.CountryCode; + evt.City = srcGeo.City; + evt.Latitude = srcGeo.Latitude; + evt.Longitude = srcGeo.Longitude; + evt.Asn = srcGeo.Asn; + evt.AsnOrg = srcGeo.AsnOrg; + + var dstGeo = LookupCached(evt.DestIp); + evt.DestCountryCode = dstGeo.CountryCode; + evt.DestCity = dstGeo.City; + evt.DestLatitude = dstGeo.Latitude; + evt.DestLongitude = dstGeo.Longitude; + evt.DestAsn = dstGeo.Asn; + evt.DestAsnOrg = dstGeo.AsnOrg; + + evt.GeoEnriched = true; } } diff --git a/src/NetworkOptimizer.Threats/Interfaces/IThreatRepository.cs b/src/NetworkOptimizer.Threats/Interfaces/IThreatRepository.cs index 509358d93..d6066fe54 100644 --- a/src/NetworkOptimizer.Threats/Interfaces/IThreatRepository.cs +++ b/src/NetworkOptimizer.Threats/Interfaces/IThreatRepository.cs @@ -26,6 +26,15 @@ public interface IThreatRepository Task> GetEventsAsync(DateTime from, DateTime to, string? sourceIp = null, int? destPort = null, KillChainStage? stage = null, int limit = 1000, CancellationToken cancellationToken = default); Task GetThreatSummaryAsync(DateTime from, DateTime to, CancellationToken cancellationToken = default); Task> GetTopSourcesAsync(DateTime from, DateTime to, int count = 10, CancellationToken cancellationToken = default); + + /// + /// Return aggregated event counts grouped by source IP, restricted to source IPs that + /// match an enabled noise filter of the given category. Bypasses the BaseQuery noise + /// filter exclusion (since the whole point is to see what was suppressed). Used by + /// the audit report to render the "Known Infrastructure Activity" and "Trusted User + /// Activity" sub-tables. + /// + Task> GetSourcesByCategoryAsync(DateTime from, DateTime to, ThreatFilterCategory category, int count = 20, CancellationToken cancellationToken = default); Task> GetTopTargetedPortsAsync(DateTime from, DateTime to, int count = 10, CancellationToken cancellationToken = default); Task> GetCountryDistributionAsync(DateTime from, DateTime to, ThreatAction? actionFilter = null, CancellationToken cancellationToken = default); Task> GetTimelineAsync(DateTime from, DateTime to, int bucketMinutes = 60, CancellationToken cancellationToken = default); @@ -64,6 +73,21 @@ public interface IThreatRepository Task SaveNoiseFilterAsync(ThreatNoiseFilter filter, CancellationToken cancellationToken = default); Task DeleteNoiseFilterAsync(int filterId, CancellationToken cancellationToken = default); Task ToggleNoiseFilterAsync(int filterId, bool enabled, CancellationToken cancellationToken = default); + + /// + /// Demote a system filter (IsSystem=false) and disable it (Enabled=false). Used when + /// the optimizer's self-IP changes - the prior entry is no longer "the optimizer's + /// own IP" so we strip the system lock and disable it, but keep the row for audit + /// history. User can delete via UI when ready. + /// + Task DemoteAndDisableSystemFilterAsync(int filterId, CancellationToken cancellationToken = default); + + /// + /// Re-promote a demoted filter back to IsSystem=true and re-enable it. Used when the + /// optimizer's self-IP changes back to a previously-known address rather than creating + /// a duplicate row. + /// + Task PromoteToSystemFilterAsync(int filterId, CancellationToken cancellationToken = default); } /// @@ -91,6 +115,18 @@ public record SourceIpSummary public string? AsnOrg { get; set; } public int MaxSeverity { get; init; } + /// + /// When returned from GetSourcesByCategoryAsync, the human-readable label from + /// the matching noise filter (e.g., "Network Optimizer (self)"). + /// + public string? Label { get; set; } + + /// + /// When returned from GetSourcesByCategoryAsync, the category of the matching + /// noise filter. Null for results from GetTopSourcesAsync. + /// + public ThreatFilterCategory? MatchedFilterCategory { get; set; } + // CrowdSec CTI enrichment (populated post-query by dashboard service) public string? CrowdSecReputation { get; set; } public int? ThreatScore { get; set; } diff --git a/src/NetworkOptimizer.Threats/Models/ThreatEvent.cs b/src/NetworkOptimizer.Threats/Models/ThreatEvent.cs index 89b49a439..eee9ce0f8 100644 --- a/src/NetworkOptimizer.Threats/Models/ThreatEvent.cs +++ b/src/NetworkOptimizer.Threats/Models/ThreatEvent.cs @@ -49,7 +49,8 @@ public class ThreatEvent /// public string InnerAlertId { get; set; } = string.Empty; - // --- Geo/ASN enrichment --- + // --- Source IP geo/ASN enrichment --- + // These reflect the SOURCE IP. For RFC1918 sources, all fields remain null. public string? CountryCode { get; set; } public string? City { get; set; } public int? Asn { get; set; } @@ -57,6 +58,25 @@ public class ThreatEvent public double? Latitude { get; set; } public double? Longitude { get; set; } + // --- Destination IP geo/ASN enrichment --- + // These reflect the DEST IP. Populated for traffic-flow events where the + // external endpoint is the destination. Kept separate from the source fields + // so source-IP grouping (Top Threat Sources) does not display destination + // ASNs as if they belonged to the source. + public string? DestCountryCode { get; set; } + public string? DestCity { get; set; } + public int? DestAsn { get; set; } + public string? DestAsnOrg { get; set; } + public double? DestLatitude { get; set; } + public double? DestLongitude { get; set; } + + /// + /// True once geo enrichment has been attempted on this event. Drives the + /// backfill loop's predicate so RFC1918 events (which will always have null + /// source geo) are not re-processed forever. + /// + public bool GeoEnriched { get; set; } + /// /// Kill chain classification assigned by the classifier. /// diff --git a/src/NetworkOptimizer.Threats/Models/ThreatFilterCategory.cs b/src/NetworkOptimizer.Threats/Models/ThreatFilterCategory.cs new file mode 100644 index 000000000..189fff5bd --- /dev/null +++ b/src/NetworkOptimizer.Threats/Models/ThreatFilterCategory.cs @@ -0,0 +1,30 @@ +namespace NetworkOptimizer.Threats.Models; + +/// +/// Classifies a noise filter to control how matched events surface in reports +/// and dashboards. All categories still exclude events from BaseQuery, but the +/// audit PDF surfaces Infrastructure and TrustedUser in separate categorized +/// sub-tables so the user can see what was suppressed and why, instead of having +/// it silently disappear from the threat table. +/// +public enum ThreatFilterCategory +{ + /// + /// Generic noise. Hidden everywhere with no further accounting. + /// + Noise = 0, + + /// + /// Known infrastructure (the optimizer LXC itself, local DNS proxies, etc.). + /// Hidden from the top threat sources table, surfaced separately as + /// "Known Infrastructure Activity" with event counts and labels. + /// + Infrastructure = 1, + + /// + /// Trusted user devices (workstations, phones). These generate recon-class + /// events from normal browsing and discovery. Hidden from the top threat + /// sources table, surfaced separately as "Trusted User Activity". + /// + TrustedUser = 2 +} diff --git a/src/NetworkOptimizer.Threats/Models/ThreatNoiseFilter.cs b/src/NetworkOptimizer.Threats/Models/ThreatNoiseFilter.cs index d892f6716..4fb869f5a 100644 --- a/src/NetworkOptimizer.Threats/Models/ThreatNoiseFilter.cs +++ b/src/NetworkOptimizer.Threats/Models/ThreatNoiseFilter.cs @@ -34,6 +34,25 @@ public class ThreatNoiseFilter /// public string Description { get; set; } = string.Empty; + /// + /// Classifies the filter so the audit report can surface Infrastructure and + /// TrustedUser matches in separate sub-tables, while Noise stays fully hidden. + /// + public ThreatFilterCategory Category { get; set; } = ThreatFilterCategory.Noise; + + /// + /// Optional short label shown alongside the IP in categorized sub-tables + /// (e.g., "Network Optimizer (self)", "DNS proxy"). Distinct from + /// Description, which is shown in the management UI. + /// + public string? Label { get; set; } + + /// + /// System-managed entry that the UI prevents deleting or disabling. Used for + /// the auto-detected self entry created at startup. + /// + public bool IsSystem { get; set; } + public bool Enabled { get; set; } = true; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/src/NetworkOptimizer.Threats/ThreatCollectionService.cs b/src/NetworkOptimizer.Threats/ThreatCollectionService.cs index a398966fa..9233c1bde 100644 --- a/src/NetworkOptimizer.Threats/ThreatCollectionService.cs +++ b/src/NetworkOptimizer.Threats/ThreatCollectionService.cs @@ -1,3 +1,6 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -98,6 +101,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // Attempt auto-download of MaxMind databases if configured and missing/stale await TryAutoDownloadGeoDatabasesAsync(stoppingToken); + // Ensure a locked Infrastructure-category noise filter exists for our own IP + // so the optimizer's own scanning traffic does not dominate the audit report's + // Top Threat Sources table. + await EnsureSelfInfrastructureFilterAsync(stoppingToken); + while (!stoppingToken.IsCancellationRequested) { try @@ -742,6 +750,138 @@ private async Task LoadConfigAsync(IThreatSettingsAccessor settings, Cancellatio _retentionDays = days; } + /// + /// Detect the primary local IPv4 address and ensure an enabled, system-managed + /// Infrastructure-category noise filter exists for it. This stops the optimizer's + /// own port scans and probes from dominating the Top Threat Sources table in + /// audit PDFs. The entry is marked IsSystem so the UI prevents deletion while + /// the LXC is at this IP. + /// + /// On IP change (e.g., new DHCP lease) the prior system entry is demoted to + /// IsSystem=false and disabled, but kept in the table so the user can review + /// it for audit-history purposes and clean up when ready. If the same IP later + /// returns, the existing entry is re-promoted to IsSystem=true and re-enabled + /// rather than creating a duplicate. + /// + private async Task EnsureSelfInfrastructureFilterAsync(CancellationToken cancellationToken) + { + try + { + var selfIp = GetPrimaryLocalIpv4(); + if (string.IsNullOrEmpty(selfIp)) + { + _logger.LogDebug("Could not determine primary local IPv4 address, skipping self-filter registration"); + return; + } + + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + + var existing = await repository.GetNoiseFiltersAsync(cancellationToken); + + // If an active system entry for the current IP already exists, nothing to do. + var activeSystemMatch = existing.FirstOrDefault(f => + f.IsSystem && + f.Enabled && + f.Category == ThreatFilterCategory.Infrastructure && + string.Equals(f.SourceIp, selfIp, StringComparison.Ordinal)); + if (activeSystemMatch != null) + return; + + // Step 1: demote and disable any active system self-entries for OTHER IPs. + // The IP changed, so the old entry no longer represents this host. Keep the + // row for audit history; user can delete via UI once it's no longer needed. + var staleSystemEntries = existing + .Where(f => f.IsSystem && + f.Category == ThreatFilterCategory.Infrastructure && + (f.Label ?? string.Empty).Contains("(self)", StringComparison.OrdinalIgnoreCase) && + !string.Equals(f.SourceIp, selfIp, StringComparison.Ordinal)) + .ToList(); + + foreach (var stale in staleSystemEntries) + { + await repository.DemoteAndDisableSystemFilterAsync(stale.Id, cancellationToken); + _logger.LogInformation( + "Demoted stale system self-filter for {OldIp} (IP changed to {NewIp})", + stale.SourceIp, selfIp); + } + + // Step 2: if there is an existing entry for the current IP that was previously + // demoted (we came back to this IP), re-promote and re-enable it rather than + // creating a duplicate. Match on exact IP and Infrastructure category. + var revivable = existing.FirstOrDefault(f => + f.Category == ThreatFilterCategory.Infrastructure && + string.Equals(f.SourceIp, selfIp, StringComparison.Ordinal) && + (f.Label ?? string.Empty).Contains("(self)", StringComparison.OrdinalIgnoreCase)); + + if (revivable != null) + { + await repository.PromoteToSystemFilterAsync(revivable.Id, cancellationToken); + _logger.LogInformation("Re-promoted existing self-filter for {SelfIp}", selfIp); + return; + } + + // Step 3: no existing entry, create a fresh one. + var filter = new ThreatNoiseFilter + { + SourceIp = selfIp, + Category = ThreatFilterCategory.Infrastructure, + Label = "Network Optimizer (self)", + Description = "Auto-detected. Suppresses the optimizer's own scanning traffic from threat tables.", + IsSystem = true, + Enabled = true + }; + + await repository.SaveNoiseFilterAsync(filter, cancellationToken); + _logger.LogInformation("Registered system Infrastructure filter for self IP {SelfIp}", selfIp); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to ensure self infrastructure filter"); + } + } + + /// + /// Return the primary local IPv4 address by inspecting the route used for + /// outbound traffic. Uses a UDP socket connect (no packets actually sent) + /// to a public-routed address to ask the OS which interface would be used. + /// + private static string? GetPrimaryLocalIpv4() + { + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + // Connect on UDP does not transmit anything; it just selects the route. + // 1.1.1.1 is a stable public IP for route selection. + socket.Connect("1.1.1.1", 65530); + if (socket.LocalEndPoint is IPEndPoint ep) + return ep.Address.ToString(); + } + catch + { + // Fall through to interface enumeration. + } + + // Fallback: pick the first up, non-loopback, non-virtual interface with an IPv4 address. + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.OperationalStatus != OperationalStatus.Up) continue; + if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; + if (nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) continue; + + foreach (var addr in nic.GetIPProperties().UnicastAddresses) + { + if (addr.Address.AddressFamily == AddressFamily.InterNetwork && + !IPAddress.IsLoopback(addr.Address)) + { + return addr.Address.ToString(); + } + } + } + + return null; + } + private async Task TryAutoDownloadGeoDatabasesAsync(CancellationToken cancellationToken) { if (_geoService.IsCityAvailable && _geoService.IsAsnAvailable) diff --git a/src/NetworkOptimizer.Web/Components/Pages/Settings.razor b/src/NetworkOptimizer.Web/Components/Pages/Settings.razor index db3d70903..19dde0c3a 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Settings.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Settings.razor @@ -1655,6 +1655,32 @@ +

Audit Report Display Limits

+

+ How many source IPs appear in each section of the audit PDF / Markdown report. +

+ +
+ + + Maximum rows in the main "Top Threat Sources" table. Default: 5. +
+ +
+ + + Maximum rows in each categorized sub-table (Known Infrastructure Activity, Trusted User Activity). Default: 10. +
+ +
+ + + Candidate row count fetched from the DB per category before trimming to the display limit above. Default: 20. +
+
GeoLite2 Database: @@ -2296,6 +2322,9 @@ private bool threatCollectionEnabled = true; private int threatPollInterval = 5; private int threatRetentionDays = 90; + private int threatTopSourcesMainLimit = 5; + private int threatTopSourcesCategoryLimit = 10; + private int threatSourcesByCategoryQueryLimit = 20; private string threatGeoStatus = "Checking..."; private bool savingThreatSettings = false; private string threatSettingsMessage = ""; @@ -4421,6 +4450,15 @@ var retention = await SystemSettings.GetAsync("threats.retention_days"); if (int.TryParse(retention, out var r)) threatRetentionDays = r; + var topSrcMain = await SystemSettings.GetAsync("threats.top_sources_main_limit"); + if (int.TryParse(topSrcMain, out var tsm) && tsm > 0) threatTopSourcesMainLimit = tsm; + + var topSrcCat = await SystemSettings.GetAsync("threats.top_sources_category_limit"); + if (int.TryParse(topSrcCat, out var tsc) && tsc > 0) threatTopSourcesCategoryLimit = tsc; + + var srcByCat = await SystemSettings.GetAsync("threats.sources_by_category_query_limit"); + if (int.TryParse(srcByCat, out var sbc) && sbc > 0) threatSourcesByCategoryQueryLimit = sbc; + // Build geo database status from service properties if (GeoService.IsCityAvailable && GeoService.IsAsnAvailable) threatGeoStatus = "City and ASN databases loaded"; @@ -4468,6 +4506,18 @@ await SystemSettings.SetAsync("threats.poll_interval_minutes", threatPollInterval.ToString()); await SystemSettings.SetAsync("threats.retention_days", threatRetentionDays.ToString()); + // Clamp to sane ranges before persisting. UI input enforces min/max but a + // direct DB write could bypass that, so guard again here. + var mainLimit = Math.Clamp(threatTopSourcesMainLimit, 1, 100); + var categoryLimit = Math.Clamp(threatTopSourcesCategoryLimit, 1, 100); + var categoryQueryLimit = Math.Clamp(threatSourcesByCategoryQueryLimit, 1, 500); + threatTopSourcesMainLimit = mainLimit; + threatTopSourcesCategoryLimit = categoryLimit; + threatSourcesByCategoryQueryLimit = categoryQueryLimit; + await SystemSettings.SetAsync("threats.top_sources_main_limit", mainLimit.ToString()); + await SystemSettings.SetAsync("threats.top_sources_category_limit", categoryLimit.ToString()); + await SystemSettings.SetAsync("threats.sources_by_category_query_limit", categoryQueryLimit.ToString()); + if (!string.IsNullOrEmpty(maxmindAccountId)) { var encAcct = CredentialService.Encrypt(maxmindAccountId); diff --git a/src/NetworkOptimizer.Web/Components/Pages/ThreatDashboard.razor b/src/NetworkOptimizer.Web/Components/Pages/ThreatDashboard.razor index 5b53701e6..3adf23e48 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/ThreatDashboard.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/ThreatDashboard.razor @@ -195,13 +195,26 @@
+
+ + +
+
+ + +
- @if (!string.IsNullOrEmpty(_newFilterSourceIp) || !string.IsNullOrEmpty(_newFilterDestIp) || !string.IsNullOrEmpty(_newFilterDestPort) || !string.IsNullOrEmpty(_newFilterDescription)) + @if (!string.IsNullOrEmpty(_newFilterSourceIp) || !string.IsNullOrEmpty(_newFilterDestIp) || !string.IsNullOrEmpty(_newFilterDestPort) || !string.IsNullOrEmpty(_newFilterDescription) || !string.IsNullOrEmpty(_newFilterLabel)) { } @@ -217,8 +230,10 @@ Source IP Dest IP Dest Port + Category + Label Description - Actions + Actions @@ -228,15 +243,25 @@ @(filter.SourceIp ?? "*") @(filter.DestIp ?? "*") @(filter.DestPort?.ToString() ?? "*") - @filter.Description + @FormatCategory(filter.Category) + @(filter.Label ?? "") + + @filter.Description + @if (filter.IsSystem) + { + system + } +
+ disabled="@filter.IsSystem" + data-tooltip="@(filter.IsSystem ? "System entry - cannot delete" : "Delete filter")" data-tooltip-hover-only>X
@@ -457,6 +482,7 @@ else ASN Events Severity + Category @if (_crowdSecEnabled) { Reputation @@ -475,6 +501,20 @@ else @GetSeverityLabel(source.MaxSeverity) + + @if (source.MatchedFilterCategory == ThreatFilterCategory.Infrastructure) + { + Infrastructure + } + else if (source.MatchedFilterCategory == ThreatFilterCategory.TrustedUser) + { + Trusted User + } + else + { + - + } + @if (_crowdSecEnabled) { @@ -1755,6 +1795,8 @@ else .category-direction { background: rgba(59, 130, 246, 0.1); color: var(--text-secondary); } .category-type { background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); } .category-neutral { background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); } + .category-infrastructure { background: rgba(127, 119, 221, 0.15); color: #7f77dd; } + .category-trusted-user { background: rgba(29, 158, 117, 0.15); color: #1d9e75; } /* Noise filter active count badge */ .noise-filter-badge { @@ -2205,6 +2247,8 @@ else private string _newFilterDestIp = ""; private string _newFilterDestPort = ""; private string _newFilterDescription = ""; + private ThreatFilterCategory _newFilterCategory = ThreatFilterCategory.Noise; + private string _newFilterLabel = ""; // Search state private string _searchText = ""; @@ -3751,17 +3795,25 @@ else SourceIp = string.IsNullOrWhiteSpace(_newFilterSourceIp) ? null : _newFilterSourceIp.Trim(), DestIp = string.IsNullOrWhiteSpace(_newFilterDestIp) ? null : _newFilterDestIp.Trim(), DestPort = int.TryParse(_newFilterDestPort, out var p) ? p : null, + Category = _newFilterCategory, + Label = string.IsNullOrWhiteSpace(_newFilterLabel) ? null : _newFilterLabel.Trim(), Description = string.IsNullOrWhiteSpace(_newFilterDescription) ? BuildFilterDescription() : _newFilterDescription.Trim() }; // Must have at least one field if (filter.SourceIp == null && filter.DestIp == null && filter.DestPort == null) return; + // Infrastructure and TrustedUser categories require a source IP - otherwise there + // is no way to surface the matched events in the categorized sub-tables. + if (filter.Category != ThreatFilterCategory.Noise && filter.SourceIp == null) return; + await DashboardService.SaveNoiseFilterAsync(filter); _newFilterSourceIp = ""; _newFilterDestIp = ""; _newFilterDestPort = ""; _newFilterDescription = ""; + _newFilterLabel = ""; + _newFilterCategory = ThreatFilterCategory.Noise; await LoadNoiseFiltersAsync(); await LoadDataAsync(); } @@ -3772,6 +3824,8 @@ else _newFilterDestIp = ""; _newFilterDestPort = ""; _newFilterDescription = ""; + _newFilterLabel = ""; + _newFilterCategory = ThreatFilterCategory.Noise; } private string BuildFilterDescription() @@ -3785,6 +3839,8 @@ else private async Task ToggleFilterAsync(ThreatNoiseFilter filter) { + // System entries cannot be toggled. UI also disables the button. + if (filter.IsSystem) return; await DashboardService.ToggleNoiseFilterAsync(filter.Id, !filter.Enabled); await LoadNoiseFiltersAsync(); await LoadDataAsync(); @@ -3792,11 +3848,20 @@ else private async Task DeleteFilterAsync(ThreatNoiseFilter filter) { + // System entries cannot be deleted. UI also disables the button. + if (filter.IsSystem) return; await DashboardService.DeleteNoiseFilterAsync(filter.Id); await LoadNoiseFiltersAsync(); await LoadDataAsync(); } + private static string FormatCategory(ThreatFilterCategory category) => category switch + { + ThreatFilterCategory.Infrastructure => "Infrastructure", + ThreatFilterCategory.TrustedUser => "Trusted User", + _ => "Noise" + }; + private async Task QuickFilterFromDrilldown(string? sourceIp, string? destIp, int? destPort) { _newFilterSourceIp = sourceIp ?? ""; diff --git a/src/NetworkOptimizer.Web/Services/AuditService.cs b/src/NetworkOptimizer.Web/Services/AuditService.cs index 27a493ac2..ce3329b33 100644 --- a/src/NetworkOptimizer.Web/Services/AuditService.cs +++ b/src/NetworkOptimizer.Web/Services/AuditService.cs @@ -699,12 +699,41 @@ await _alertEventBus.PublishAsync(new AlertEvent { var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); var now = DateTime.UtcNow; + + // Display limits are configurable via system settings. Defaults preserve + // historical behavior: 5 rows in the main Top Threat Sources table, 10 rows + // in each categorized sub-table, and 20 candidate sources fetched from the + // category query before trimming to the sub-table display cap. + var topSourcesLimit = await GetIntSettingAsync("threats.top_sources_main_limit", 5); + var categorySubTableLimit = await GetIntSettingAsync("threats.top_sources_category_limit", 10); + var categoryQueryLimit = await GetIntSettingAsync("threats.sources_by_category_query_limit", 20); + + // Apply enabled noise filters so the audit report matches what the user + // sees on the threat dashboard. Without this, the audit always sees raw + // events including infrastructure self-scans and trusted-user noise. + var allFilters = await _threatRepository.GetNoiseFiltersAsync(); + var enabledFilters = allFilters.Where(f => f.Enabled).ToList(); + _threatRepository.SetNoiseFilters(enabledFilters); + var summary = await _threatRepository.GetThreatSummaryAsync(thirtyDaysAgo, now); if (summary.TotalEvents == 0) return null; - var topSources = await _threatRepository.GetTopSourcesAsync(thirtyDaysAgo, now, 5); + var topSources = await _threatRepository.GetTopSourcesAsync(thirtyDaysAgo, now, topSourcesLimit); var killChain = await _threatRepository.GetKillChainDistributionAsync(thirtyDaysAgo, now); + // Categorized sub-tables: events matching Infrastructure / TrustedUser + // filters are excluded from TopSources above but surfaced here so the + // user can see what was suppressed and why. + var infraSources = enabledFilters.Any(f => f.Category == ThreatFilterCategory.Infrastructure && f.SourceIp != null) + ? await _threatRepository.GetSourcesByCategoryAsync(thirtyDaysAgo, now, ThreatFilterCategory.Infrastructure, categoryQueryLimit) + : new List(); + + var trustedSources = enabledFilters.Any(f => f.Category == ThreatFilterCategory.TrustedUser && f.SourceIp != null) + ? await _threatRepository.GetSourcesByCategoryAsync(thirtyDaysAgo, now, ThreatFilterCategory.TrustedUser, categoryQueryLimit) + : new List(); + + var suppressedEventCount = infraSources.Sum(s => s.EventCount) + trustedSources.Sum(s => s.EventCount); + return new Reports.ThreatSummaryData { TotalEvents = summary.TotalEvents, @@ -719,7 +748,24 @@ await _alertEventBus.PublishAsync(new AlertEvent CountryCode = s.CountryCode, AsnOrg = s.AsnOrg, EventCount = s.EventCount - }).ToList() + }).ToList(), + InfrastructureSources = infraSources.Take(categorySubTableLimit).Select(s => new Reports.ThreatSourceEntry + { + Ip = s.SourceIp, + CountryCode = s.CountryCode, + AsnOrg = s.AsnOrg, + EventCount = s.EventCount, + Label = s.Label + }).ToList(), + TrustedUserSources = trustedSources.Take(categorySubTableLimit).Select(s => new Reports.ThreatSourceEntry + { + Ip = s.SourceIp, + CountryCode = s.CountryCode, + AsnOrg = s.AsnOrg, + EventCount = s.EventCount, + Label = s.Label + }).ToList(), + SuppressedEventCount = suppressedEventCount }; } catch (Exception ex) @@ -729,6 +775,15 @@ await _alertEventBus.PublishAsync(new AlertEvent } } + /// + /// Read an integer setting with a default if missing or unparseable. + /// + private async Task GetIntSettingAsync(string key, int defaultValue) + { + var raw = await _settingsService.GetAsync(key); + return int.TryParse(raw, out var v) && v > 0 ? v : defaultValue; + } + public Reports.ReportData BuildReportData(AuditResult result, string? clientName = null, Reports.ThreatSummaryData? threatSummary = null) { // Derive client name from gateway device if not provided diff --git a/src/NetworkOptimizer.Web/Services/ThreatDashboardService.cs b/src/NetworkOptimizer.Web/Services/ThreatDashboardService.cs index d93b251f6..c49a78a34 100644 --- a/src/NetworkOptimizer.Web/Services/ThreatDashboardService.cs +++ b/src/NetworkOptimizer.Web/Services/ThreatDashboardService.cs @@ -65,14 +65,35 @@ public async Task GetDashboardDataAsync(DateTime from, Date { try { - await ApplyNoiseFiltersToRepository(cancellationToken); + // Load all enabled filters once. Top Sources uses Noise-only so categorized + // sources (Infrastructure / TrustedUser) appear in the main table with a + // badge. Other dashboard widgets (kill chain, ports, patterns) keep the + // full-filter behavior so categorized noise does not dominate charts. + var allEnabled = (await _repository.GetNoiseFiltersAsync(cancellationToken)) + .Where(f => f.Enabled) + .ToList(); + var nonNoiseFilters = allEnabled + .Where(f => f.Category != ThreatFilterCategory.Noise) + .ToList(); + + // Top Sources: respect the master filter-active toggle, but when filters are + // active, only Noise-category exclusions apply here. Infrastructure and + // TrustedUser rows remain in the table for the badge to surface. + if (FiltersDisabled) + { + _repository.SetNoiseFilters([]); + } + else + { + _repository.SetNoiseFilters( + allEnabled.Where(f => f.Category == ThreatFilterCategory.Noise).ToList()); + } _repository.SetSeverityFilter(SeverityFilter); - var summary = await _repository.GetThreatSummaryAsync(from, to, cancellationToken); - var killChain = await _repository.GetKillChainDistributionAsync(from, to, cancellationToken); var topSources = await _repository.GetTopSourcesAsync(from, to, 10, cancellationToken); - // Re-enrich geo data directly on source IPs. - // Event-level CountryCode/AsnOrg may reflect the destination for flow events with private sources. + // Re-enrich geo data directly on source IPs. Event-level CountryCode/AsnOrg + // is null for RFC1918 sources post-fix, but this also handles older rows + // that might still have stale values until the migration scrub runs. foreach (var source in topSources) { var geo = _geoService.Enrich(source.SourceIp); @@ -80,10 +101,27 @@ public async Task GetDashboardDataAsync(DateTime from, Date source.City = geo.City; source.Asn = geo.Asn; source.AsnOrg = geo.AsnOrg; + + // Attach matched category for badge rendering. Exact-match filters take + // precedence over CIDR matches when both apply. + var match = nonNoiseFilters + .Where(f => f.Matches(source.SourceIp, null, null)) + .OrderBy(f => f.SourceIp != null && f.SourceIp.Contains('/') ? 1 : 0) + .FirstOrDefault(); + if (match != null) + { + source.MatchedFilterCategory = match.Category; + source.Label = match.Label; + } } + // All other dashboard queries: apply the master toggle uniformly with the + // full filter set, so categorized noise stays out of charts and pattern lists. + await ApplyNoiseFiltersToRepository(cancellationToken); var topPorts = await _repository.GetTopTargetedPortsAsync(from, to, 10, cancellationToken); var patterns = await _repository.GetPatternsAsync(from, to, limit: 20, cancellationToken: cancellationToken); + var summary = await _repository.GetThreatSummaryAsync(from, to, cancellationToken); + var killChain = await _repository.GetKillChainDistributionAsync(from, to, cancellationToken); _repository.SetSeverityFilter(null); // Enrich from DB cache (instant, no API calls) so previously looked-up IPs show badges diff --git a/tests/NetworkOptimizer.Storage.Tests/ThreatNoiseFilterPersistenceTests.cs b/tests/NetworkOptimizer.Storage.Tests/ThreatNoiseFilterPersistenceTests.cs new file mode 100644 index 000000000..85876fe64 --- /dev/null +++ b/tests/NetworkOptimizer.Storage.Tests/ThreatNoiseFilterPersistenceTests.cs @@ -0,0 +1,120 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Storage.Repositories; +using NetworkOptimizer.Threats.Models; +using Xunit; + +namespace NetworkOptimizer.Storage.Tests; + +/// +/// Verifies persistence of the new Category / Label / IsSystem fields on +/// ThreatNoiseFilter through the repository layer. +/// +public class ThreatNoiseFilterPersistenceTests : IDisposable +{ + private readonly NetworkOptimizerDbContext _context; + private readonly ThreatRepository _repository; + + public ThreatNoiseFilterPersistenceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _context = new NetworkOptimizerDbContext(options); + var logger = new Mock>(); + _repository = new ThreatRepository(_context, logger.Object); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public async Task SaveNoiseFilterAsync_RoundTripsCategoryLabelAndIsSystem() + { + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "Network Optimizer (self)", + Description = "Auto-detected.", + IsSystem = true, + Enabled = true + }; + + await _repository.SaveNoiseFilterAsync(filter); + + var stored = (await _repository.GetNoiseFiltersAsync()).Single(); + stored.Category.Should().Be(ThreatFilterCategory.Infrastructure); + stored.Label.Should().Be("Network Optimizer (self)"); + stored.IsSystem.Should().BeTrue(); + } + + [Fact] + public async Task SaveNoiseFilterAsync_LegacyFilterDefaultsToNoiseCategory() + { + // A pre-PR filter written without an explicit Category should land as Noise. + // Documents the implicit default that pre-existing rows pick up via the + // migration's defaultValue: 0 on the Category column. + var filter = new ThreatNoiseFilter + { + SourceIp = "198.51.100.50", + Description = "legacy filter" + }; + + await _repository.SaveNoiseFilterAsync(filter); + + var stored = (await _repository.GetNoiseFiltersAsync()).Single(); + stored.Category.Should().Be(ThreatFilterCategory.Noise); + stored.IsSystem.Should().BeFalse(); + stored.Label.Should().BeNull(); + } + + [Fact] + public async Task DemoteAndDisableSystemFilterAsync_StripsIsSystemAndDisables() + { + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "Network Optimizer (self)", + Description = "self", + IsSystem = true, + Enabled = true + }; + await _repository.SaveNoiseFilterAsync(filter); + var stored = (await _repository.GetNoiseFiltersAsync()).Single(); + + await _repository.DemoteAndDisableSystemFilterAsync(stored.Id); + + var after = (await _repository.GetNoiseFiltersAsync()).Single(); + after.IsSystem.Should().BeFalse("system lock removed so user can manage it"); + after.Enabled.Should().BeFalse("disabled since the IP no longer represents this host"); + after.SourceIp.Should().Be("192.0.2.10", "row is kept in the table for audit history"); + after.Label.Should().Be("Network Optimizer (self)", "label preserved so user knows what it was"); + } + + [Fact] + public async Task PromoteToSystemFilterAsync_RestoresIsSystemAndEnables() + { + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "Network Optimizer (self)", + Description = "self", + IsSystem = false, + Enabled = false + }; + await _repository.SaveNoiseFilterAsync(filter); + var stored = (await _repository.GetNoiseFiltersAsync()).Single(); + + await _repository.PromoteToSystemFilterAsync(stored.Id); + + var after = (await _repository.GetNoiseFiltersAsync()).Single(); + after.IsSystem.Should().BeTrue(); + after.Enabled.Should().BeTrue(); + } +} diff --git a/tests/NetworkOptimizer.Storage.Tests/ThreatRepositoryCategoryTests.cs b/tests/NetworkOptimizer.Storage.Tests/ThreatRepositoryCategoryTests.cs new file mode 100644 index 000000000..d5b300a27 --- /dev/null +++ b/tests/NetworkOptimizer.Storage.Tests/ThreatRepositoryCategoryTests.cs @@ -0,0 +1,286 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Storage.Repositories; +using NetworkOptimizer.Threats.Models; +using Xunit; + +namespace NetworkOptimizer.Storage.Tests; + +/// +/// Covers the source/destination geo separation in GetTopSourcesAsync and the new +/// GetSourcesByCategoryAsync entry point that powers the audit report's categorized +/// "Known Infrastructure Activity" and "Trusted User Activity" sub-tables. +/// +public class ThreatRepositoryCategoryTests : IDisposable +{ + private readonly NetworkOptimizerDbContext _context; + private readonly ThreatRepository _repository; + private readonly DateTime _now = DateTime.UtcNow; + + public ThreatRepositoryCategoryTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _context = new NetworkOptimizerDbContext(options); + var logger = new Mock>(); + _repository = new ThreatRepository(_context, logger.Object); + } + + public void Dispose() => _context.Dispose(); + + private ThreatEvent MakeEvent(string sourceIp, string destIp = "203.0.113.10", + string? sourceCountry = null, string? sourceAsnOrg = null, + string? destCountry = "US", string? destAsnOrg = "Example LLC", + int severity = 2) + { + return new ThreatEvent + { + Timestamp = _now, + SourceIp = sourceIp, + DestIp = destIp, + DestPort = 443, + Protocol = "tcp", + SignatureName = "test", + Category = "test", + Severity = severity, + Action = ThreatAction.Blocked, + InnerAlertId = Guid.NewGuid().ToString(), + CountryCode = sourceCountry, + AsnOrg = sourceAsnOrg, + DestCountryCode = destCountry, + DestAsnOrg = destAsnOrg, + GeoEnriched = true + }; + } + + // --- GetTopSourcesAsync: source vs destination separation --- + + [Fact] + public async Task GetTopSourcesAsync_PrivateSource_DoesNotInheritDestinationAsn() + { + // This is the bug the PR fixes. Pre-fix, the destination ASN (NextDNS, Google) + // was written onto the event's source-row fields and surfaced here. Post-fix, + // source enrichment is null for RFC1918 sources, which is the truthful answer. + _context.ThreatEvents.Add(MakeEvent( + sourceIp: "192.0.2.20", + destIp: "198.51.100.65", + sourceCountry: null, sourceAsnOrg: null, + destCountry: "US", destAsnOrg: "NextDNS, Inc.")); + await _context.SaveChangesAsync(); + + var result = await _repository.GetTopSourcesAsync(_now.AddHours(-1), _now.AddHours(1), 10); + + result.Should().HaveCount(1); + result[0].SourceIp.Should().Be("192.0.2.20"); + result[0].AsnOrg.Should().BeNull("an RFC1918 source has no public ASN"); + result[0].CountryCode.Should().BeNull("an RFC1918 source has no country"); + } + + [Fact] + public async Task GetTopSourcesAsync_PublicSource_ReturnsSourceEnrichment() + { + _context.ThreatEvents.Add(MakeEvent( + sourceIp: "8.8.8.8", + sourceCountry: "US", sourceAsnOrg: "Google LLC")); + await _context.SaveChangesAsync(); + + var result = await _repository.GetTopSourcesAsync(_now.AddHours(-1), _now.AddHours(1), 10); + + result.Should().HaveCount(1); + result[0].AsnOrg.Should().Be("Google LLC"); + result[0].CountryCode.Should().Be("US"); + } + + // --- GetSourcesByCategoryAsync --- + + [Fact] + public async Task GetSourcesByCategoryAsync_NoFiltersOfCategory_ReturnsEmpty() + { + _context.ThreatEvents.Add(MakeEvent("192.0.2.10")); + await _context.SaveChangesAsync(); + + var result = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.Infrastructure); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetSourcesByCategoryAsync_ExactMatch_ReturnsMatchingSources() + { + _context.ThreatEvents.AddRange( + MakeEvent("192.0.2.10"), + MakeEvent("192.0.2.10"), + MakeEvent("192.0.2.30")); + _context.ThreatNoiseFilters.Add(new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "Network Optimizer (self)", + Description = "self", + Enabled = true + }); + await _context.SaveChangesAsync(); + + var result = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.Infrastructure); + + result.Should().HaveCount(1); + result[0].SourceIp.Should().Be("192.0.2.10"); + result[0].EventCount.Should().Be(2); + result[0].Label.Should().Be("Network Optimizer (self)"); + result[0].MatchedFilterCategory.Should().Be(ThreatFilterCategory.Infrastructure); + } + + [Fact] + public async Task GetSourcesByCategoryAsync_CidrFilter_MatchesSubnet() + { + _context.ThreatEvents.AddRange( + MakeEvent("192.0.2.10"), + MakeEvent("192.0.2.20"), + MakeEvent("198.51.100.25")); // outside /24 + _context.ThreatNoiseFilters.Add(new ThreatNoiseFilter + { + SourceIp = "192.0.2.0/24", + Category = ThreatFilterCategory.Infrastructure, + Label = "Servers VLAN infrastructure", + Description = "servers vlan", + Enabled = true + }); + await _context.SaveChangesAsync(); + + var result = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.Infrastructure); + + result.Should().HaveCount(2); + result.Select(r => r.SourceIp).Should().BeEquivalentTo(new[] { "192.0.2.10", "192.0.2.20" }); + result.All(r => r.Label == "Servers VLAN infrastructure").Should().BeTrue(); + } + + [Fact] + public async Task GetSourcesByCategoryAsync_DisabledFilter_Excluded() + { + _context.ThreatEvents.Add(MakeEvent("192.0.2.10")); + _context.ThreatNoiseFilters.Add(new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Description = "self", + Enabled = false + }); + await _context.SaveChangesAsync(); + + var result = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.Infrastructure); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetSourcesByCategoryAsync_BypassesNoiseFilterExclusion() + { + // The whole point: even though SetNoiseFilters would normally hide events + // from this source, GetSourcesByCategoryAsync surfaces them so the audit + // report can render them in the categorized sub-table. + _context.ThreatEvents.Add(MakeEvent("192.0.2.10")); + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "self", + Description = "self", + Enabled = true + }; + _context.ThreatNoiseFilters.Add(filter); + await _context.SaveChangesAsync(); + + // Activate the filter on the repo (this is what AuditService.BuildThreatSummaryAsync does). + _repository.SetNoiseFilters(new List { filter }); + + var topSources = await _repository.GetTopSourcesAsync(_now.AddHours(-1), _now.AddHours(1)); + var infraSources = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.Infrastructure); + + topSources.Should().BeEmpty("the noise filter excludes this source from the main table"); + infraSources.Should().HaveCount(1, "the category-specific query surfaces it back"); + } + + [Fact] + public async Task GetSourcesByCategoryAsync_OnlyReturnsRequestedCategory() + { + _context.ThreatEvents.AddRange( + MakeEvent("192.0.2.10"), + MakeEvent("203.0.113.10")); + _context.ThreatNoiseFilters.AddRange( + new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "self", + Description = "infra", + Enabled = true + }, + new ThreatNoiseFilter + { + SourceIp = "203.0.113.10", + Category = ThreatFilterCategory.TrustedUser, + Label = "Engineer workstation", + Description = "trusted", + Enabled = true + }); + await _context.SaveChangesAsync(); + + var infra = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.Infrastructure); + var trusted = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.TrustedUser); + + infra.Should().ContainSingle().Which.SourceIp.Should().Be("192.0.2.10"); + trusted.Should().ContainSingle().Which.SourceIp.Should().Be("203.0.113.10"); + } + + [Fact] + public async Task GetTopSourcesAsync_NoiseOnlyFilterSet_AllowsInfrastructureRowsThrough() + { + // The dashboard's Option B layout depends on this guarantee: when only + // Noise-category filters are applied to the repo, Infrastructure and + // TrustedUser sources must still appear in GetTopSourcesAsync so the + // razor can attach a Category badge and show them in the main table. + _context.ThreatEvents.AddRange( + MakeEvent("192.0.2.10"), + MakeEvent("192.0.2.10"), + MakeEvent("198.51.100.99")); + var infraFilter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "self", + Description = "infra", + Enabled = true + }; + var noiseFilter = new ThreatNoiseFilter + { + SourceIp = "198.51.100.99", + Category = ThreatFilterCategory.Noise, + Description = "junk", + Enabled = true + }; + _context.ThreatNoiseFilters.AddRange(infraFilter, noiseFilter); + await _context.SaveChangesAsync(); + + // Simulate the dashboard service applying ONLY Noise-category filters + // so Infrastructure rows remain visible in the main table. + _repository.SetNoiseFilters(new List { noiseFilter }); + + var topSources = await _repository.GetTopSourcesAsync(_now.AddHours(-1), _now.AddHours(1), 10); + + topSources.Should().HaveCount(1, "the Noise-tagged source is excluded, but the Infrastructure one is not"); + topSources[0].SourceIp.Should().Be("192.0.2.10"); + topSources[0].EventCount.Should().Be(2); + } +} diff --git a/tests/NetworkOptimizer.Threats.Tests/CrowdSecEnrichmentServiceTests.cs b/tests/NetworkOptimizer.Threats.Tests/CrowdSecEnrichmentServiceTests.cs new file mode 100644 index 000000000..13db28be9 --- /dev/null +++ b/tests/NetworkOptimizer.Threats.Tests/CrowdSecEnrichmentServiceTests.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using NetworkOptimizer.Threats.CrowdSec; +using NetworkOptimizer.Threats.Interfaces; +using NetworkOptimizer.Threats.Models; +using Xunit; + +namespace NetworkOptimizer.Threats.Tests; + +/// +/// Tests the centralized private-IP guard on CrowdSecEnrichmentService. +/// Private IPs must short-circuit BEFORE any cache read or API call - otherwise +/// background hydration on RFC1918 sources would burn through the daily quota for +/// reputation data that CrowdSec's CTI does not have. +/// +public class CrowdSecEnrichmentServiceTests +{ + private readonly Mock> _clientLogger = new(); + private readonly Mock> _serviceLogger = new(); + private readonly Mock _httpFactory = new(); + + private CrowdSecEnrichmentService MakeService() + { + var client = new CrowdSecClient(_httpFactory.Object, _clientLogger.Object); + return new CrowdSecEnrichmentService(client, _serviceLogger.Object); + } + + [Theory] + [InlineData("10.99.99.99")] + [InlineData("192.168.1.5")] + [InlineData("172.16.5.5")] + [InlineData("127.0.0.1")] + [InlineData("169.254.10.10")] + public async Task GetReputationAsync_PrivateIp_ReturnsNotApplicable_WithoutTouchingCacheOrApi(string ip) + { + var svc = MakeService(); + var repo = new Mock(MockBehavior.Strict); + // Strict mock: any unconfigured call throws. If the guard fails to fire, + // GetCrowdSecCacheAsync would be called and this test would fail. + + var (info, outcome) = await svc.GetReputationAsync(ip, "fake-api-key", repo.Object); + + info.Should().BeNull(); + outcome.Should().Be(CrowdSecLookupOutcome.NotApplicable); + repo.Verify(r => r.GetCrowdSecCacheAsync(It.IsAny(), It.IsAny()), Times.Never); + repo.Verify(r => r.SaveCrowdSecCacheAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetReputationAsync_PublicIp_DoesNotShortCircuit() + { + // Verifies the guard is RFC1918-only and does not over-block. A public IP + // hits the cache first (which returns null here) then would query the API. + // We do not run the API since there is no HttpClient wired up; the test just + // verifies the cache layer was consulted before falling through. + var svc = MakeService(); + var repo = new Mock(); + repo.Setup(r => r.GetCrowdSecCacheAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((CrowdSecReputation?)null); + + var (_, outcome) = await svc.GetReputationAsync("8.8.8.8", "fake-key", repo.Object); + + repo.Verify(r => r.GetCrowdSecCacheAsync("8.8.8.8", It.IsAny()), Times.Once); + outcome.Should().NotBe(CrowdSecLookupOutcome.NotApplicable); + } +} diff --git a/tests/NetworkOptimizer.Threats.Tests/GeoEnrichmentServiceTests.cs b/tests/NetworkOptimizer.Threats.Tests/GeoEnrichmentServiceTests.cs new file mode 100644 index 000000000..3d8836dda --- /dev/null +++ b/tests/NetworkOptimizer.Threats.Tests/GeoEnrichmentServiceTests.cs @@ -0,0 +1,99 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using NetworkOptimizer.Threats.Enrichment; +using NetworkOptimizer.Threats.Models; +using Xunit; + +namespace NetworkOptimizer.Threats.Tests; + +/// +/// Tests the source/destination geo enrichment contract. Full MaxMind lookups need +/// .mmdb files on disk so these tests exercise the boundaries that do not depend on +/// a real database: private-IP filtering, no-DB no-op behavior, and the per-event +/// invariants the bug fix relies on. +/// +public class GeoEnrichmentServiceTests +{ + private GeoEnrichmentService Make() => + new(new Mock>().Object); + + private ThreatEvent MakeEvent(string sourceIp, string destIp) => new() + { + Timestamp = DateTime.UtcNow, + SourceIp = sourceIp, + DestIp = destIp, + DestPort = 443, + Protocol = "tcp", + SignatureName = "test", + Category = "test", + InnerAlertId = Guid.NewGuid().ToString() + }; + + [Fact] + public void Enrich_PrivateIpv4_ReturnsEmpty() + { + var svc = Make(); + + svc.Enrich("10.99.99.99").Should().Be(GeoInfo.Empty); + svc.Enrich("192.168.1.1").Should().Be(GeoInfo.Empty); + svc.Enrich("172.16.5.5").Should().Be(GeoInfo.Empty); + } + + [Fact] + public void Enrich_LoopbackAndLinkLocal_ReturnsEmpty() + { + var svc = Make(); + + svc.Enrich("127.0.0.1").Should().Be(GeoInfo.Empty); + svc.Enrich("169.254.1.1").Should().Be(GeoInfo.Empty); + } + + [Fact] + public void Enrich_InvalidIp_ReturnsEmpty() + { + var svc = Make(); + + svc.Enrich("not-an-ip").Should().Be(GeoInfo.Empty); + svc.Enrich("").Should().Be(GeoInfo.Empty); + } + + [Fact] + public void EnrichEvents_EmptyList_DoesNotThrow() + { + var svc = Make(); + var act = () => svc.EnrichEvents(new List()); + act.Should().NotThrow(); + } + + [Fact] + public void EnrichEvents_NoDatabaseLoaded_LeavesFieldsNull() + { + // With no .mmdb files on disk, both readers are null and EnrichEvents is a + // no-op. The pre-fix code path would still write destination ASN onto source + // fields under these conditions if the redirect logic had run - this test + // documents that none of that happens anymore. + var svc = Make(); + var events = new List + { + MakeEvent("192.0.2.20", "198.51.100.65") + }; + + svc.EnrichEvents(events); + + events[0].CountryCode.Should().BeNull(); + events[0].AsnOrg.Should().BeNull(); + events[0].DestCountryCode.Should().BeNull(); + events[0].DestAsnOrg.Should().BeNull(); + events[0].GeoEnriched.Should().BeFalse( + "the backfill loop relies on this flag to avoid re-processing when no DB is available"); + } + + [Fact] + public void IsCityAvailable_NoInit_ReturnsFalse() + { + var svc = Make(); + svc.IsCityAvailable.Should().BeFalse(); + svc.IsAsnAvailable.Should().BeFalse(); + } +} diff --git a/tests/NetworkOptimizer.Threats.Tests/ThreatNoiseFilterTests.cs b/tests/NetworkOptimizer.Threats.Tests/ThreatNoiseFilterTests.cs new file mode 100644 index 000000000..f64e2b822 --- /dev/null +++ b/tests/NetworkOptimizer.Threats.Tests/ThreatNoiseFilterTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using NetworkOptimizer.Threats.Models; +using Xunit; + +namespace NetworkOptimizer.Threats.Tests; + +/// +/// Verifies ThreatNoiseFilter's new Category, Label, IsSystem fields and that the +/// existing Matches() contract is unchanged by their addition. +/// +public class ThreatNoiseFilterTests +{ + [Fact] + public void DefaultCategory_IsNoise() + { + var filter = new ThreatNoiseFilter(); + filter.Category.Should().Be(ThreatFilterCategory.Noise); + } + + [Fact] + public void DefaultIsSystem_IsFalse() + { + var filter = new ThreatNoiseFilter(); + filter.IsSystem.Should().BeFalse(); + } + + [Fact] + public void Matches_ExactSourceIp_StillWorksWithCategorySet() + { + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "self" + }; + + filter.Matches("192.0.2.10", null, null).Should().BeTrue(); + filter.Matches("192.0.2.11", null, null).Should().BeFalse(); + } + + [Fact] + public void Matches_CidrSourceIp_StillWorks() + { + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.0/24", + Category = ThreatFilterCategory.Infrastructure + }; + + filter.Matches("192.0.2.10", null, null).Should().BeTrue(); + filter.Matches("192.0.2.20", null, null).Should().BeTrue(); + filter.Matches("198.51.100.25", null, null).Should().BeFalse(); + } + + [Fact] + public void Matches_SourceAndDestAndPort_AllRequired() + { + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + DestIp = "8.8.8.8", + DestPort = 53, + Category = ThreatFilterCategory.Noise + }; + + filter.Matches("192.0.2.10", "8.8.8.8", 53).Should().BeTrue(); + filter.Matches("192.0.2.10", "8.8.8.8", 443).Should().BeFalse(); + filter.Matches("192.0.2.10", "1.1.1.1", 53).Should().BeFalse(); + } +}