From 67928acd8bf8ef912242115e5a035548c7411f1e Mon Sep 17 00:00:00 2001 From: Ales Vucina <21204857+alesvucina@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:17:09 +0200 Subject: [PATCH 1/8] Add Slovenian app localization and online help --- Controls/ScanWorkspace.xaml.cs | 14 + README.md | 2 +- Services/LocaleManager.cs | 7 +- Services/ReportExport.cs | 6 +- Shell/Theme.cs | 1 + Strings/sl-SI.xaml | 539 ++++++++++++++++++++++++ TRANSLATING.md | 24 +- build/shortcut-catalog.test.js | 4 +- scan-landing/about.html | 6 +- scan-landing/help.html | 10 +- scan-landing/index.html | 6 +- scan-landing/ks-i18n.js | 284 +++++++++++++ scan-landing/ks.js | 3 +- scan-landing/mcp.html | 4 +- scan-landing/shortcuts.generated.js | 2 +- scan-landing/technical.html | 6 +- tests/SpeedTest.Tests/ReleaseUiTests.cs | 33 +- 17 files changed, 920 insertions(+), 31 deletions(-) create mode 100644 Strings/sl-SI.xaml diff --git a/Controls/ScanWorkspace.xaml.cs b/Controls/ScanWorkspace.xaml.cs index 3c8ebd8b..ccd31901 100644 --- a/Controls/ScanWorkspace.xaml.cs +++ b/Controls/ScanWorkspace.xaml.cs @@ -73,6 +73,7 @@ public ScanWorkspace(string initialTarget = "", bool demo = false) StartVendorNoticeTimer(); RestoreColumnLayout(); AddWorkspaceDeviceMenus(); + RefreshColumnHeaders(); ServicesGrid.SelectionChanged += (_, _) => { if (ServicesGrid.SelectedItem is ServiceRow row) @@ -159,6 +160,7 @@ public void SetView(string view) public void Export() => ExportButton_Click(ExportButton, new RoutedEventArgs()); public void RefreshLocale() { + RefreshColumnHeaders(); _filteredView?.Refresh(); RefreshDeviceCount(); UpdateDeepScanButton(); @@ -167,6 +169,18 @@ public void RefreshLocale() if (_showTopology) RefreshTopology(); if (_showServices) RefreshServices(); } + private void RefreshColumnHeaders() + { + // DataGrid columns are outside the visual tree, so refresh their resources explicitly. + ColIp.Header = Loc("Str_Col_Ip"); + ColHost.Header = Loc("Str_Col_Host"); + ColMac.Header = Loc("Str_Col_Mac"); + ColVendor.Header = Loc("Str_Col_Vendor"); + ColType.Header = Loc("Str_Col_Type"); + ColPorts.Header = Loc("Str_Col_Ports"); + string[] services = ["Str_Services_Service", "Str_Col_Ports", "Str_Col_DeviceName", "Str_Col_Ip", "Str_Col_Type"]; + for (int i = 0; i < services.Length; i++) ServicesGrid.Columns[i].Header = Loc(services[i]); + } public void ApplyScale(double scale) => LayoutTransform = new ScaleTransform(scale, scale); public void Dispose() { diff --git a/README.md b/README.md index 5777c2ba..ff6da18f 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Part of [killertools.net](https://killertools.net). - Headless command line for scripts, RMM, and KillerMCP: scan networks, probe or diagnose a host, inspect the active network, ping, trace routes, monitor availability, run KillerSpeed, or look up a MAC vendor. Commands support bounded inputs, structured JSON, and distinct exit codes without opening a window. - Keyboard shortcuts, on F1, which switches between a grouped shortcut list in two colored columns and a persistent keyboard map that paints each key in its category color: F5 scan/stop, Esc cancel, Ctrl+R deep rescan the selection, Ctrl+F subnet box, Ctrl+A select all, Ctrl+E export, F6 Devices, F7 Services, F8 Topology, F9 Keep Alive, F10 Terminal, F3 diagnostics, F4 speed test, Ctrl+H history, Ctrl+Shift+P profiles, Ctrl+G topology arrangement, F12 About, and device actions (Enter browser, Ctrl+P ping, Ctrl+D RDP, Ctrl+S SSH, Ctrl+C/Ctrl+Shift+C/Ctrl+Alt+C copy IP/MAC/hostname) - App-wide size control from 70% to 250%, on Ctrl+Shift+plus/minus/0 or the mouse wheel over the title-bar wordmark; text reflows at the new size and the setting is remembered -- Thirteen themes, of which Dark, Light, Black and 98SE each take one of eight accent colors for 41 looks in all, including a full Windows 98 treatment; theme, accent, language, and app size are remembered; localized in 19 languages (English, Spanish, Brazilian Portuguese, Traditional and Simplified Chinese, German, French, Turkish, Bengali, Japanese, Czech, Kazakh, Norwegian, Polish, Hungarian, Italian, Russian, Ukrainian, Vietnamese) +- Thirteen themes, of which Dark, Light, Black and 98SE each take one of eight accent colors for 41 looks in all, including a full Windows 98 treatment; theme, accent, language, and app size are remembered; localized in 20 languages (English, Spanish, Brazilian Portuguese, Traditional and Simplified Chinese, German, French, Turkish, Bengali, Japanese, Czech, Kazakh, Norwegian, Polish, Hungarian, Italian, Russian, Slovenian, Ukrainian, Vietnamese) ## Screenshots diff --git a/Services/LocaleManager.cs b/Services/LocaleManager.cs index b735b90b..5017d68d 100644 --- a/Services/LocaleManager.cs +++ b/Services/LocaleManager.cs @@ -6,12 +6,13 @@ namespace KillerScan.Services { // Mirrors KillerPDF's LocaleManager. en-US.xaml is always the base layer so any locale that // omits a key falls back to English; the chosen locale's file is layered on top. - internal enum Locale { EnUS, Es, ZhTW, ZhCN, Bn, TrTR, De, Fr, Ja, CsCZ, PlPL, HuHU, ItIT, KkKZ, RuRU, ViVN, UkUA, NbNO, PtBR } + internal enum Locale { EnUS, Es, ZhTW, ZhCN, Bn, TrTR, De, Fr, Ja, CsCZ, PlPL, HuHU, ItIT, KkKZ, RuRU, ViVN, UkUA, NbNO, PtBR, SlSI } internal static class LocaleManager { private static Locale _current = Locale.EnUS; public static Locale Current => _current; + public static string LanguageTag { get; private set; } = "en-US"; /// Call once at startup (after ThemeManager.Initialize) to restore the saved locale. public static void Initialize() @@ -51,6 +52,7 @@ internal static Locale LocaleForCulture(CultureInfo culture) "uk" => Locale.UkUA, "nb" or "no" or "nn" => Locale.NbNO, "pt" => Locale.PtBR, + "sl" => Locale.SlSI, _ => Locale.EnUS, }; } @@ -74,6 +76,7 @@ public static void Apply(Locale locale) private static void ApplyInternal(Locale locale) { var merged = Application.Current.Resources.MergedDictionaries; + LanguageTag = "en-US"; // [0] theme. [1] en-US BASE - always present so any partial locale falls back to English // for keys it doesn't translate. [2] the chosen locale's overrides (absent for English). @@ -100,6 +103,7 @@ private static void ApplyInternal(Locale locale) Locale.UkUA => new Uri("pack://application:,,,/Strings/uk-UA.xaml"), Locale.NbNO => new Uri("pack://application:,,,/Strings/nb-NO.xaml"), Locale.PtBR => new Uri("pack://application:,,,/Strings/pt-BR.xaml"), + Locale.SlSI => new Uri("pack://application:,,,/Strings/sl-SI.xaml"), _ => null, // English: base only }; @@ -109,6 +113,7 @@ private static void ApplyInternal(Locale locale) { var ov = new ResourceDictionary { Source = overrideUri }; if (merged.Count > 2) merged[2] = ov; else merged.Add(ov); + LanguageTag = System.IO.Path.GetFileNameWithoutExtension(overrideUri.AbsolutePath); } catch { diff --git a/Services/ReportExport.cs b/Services/ReportExport.cs index ebfeba14..9d49e3f5 100644 --- a/Services/ReportExport.cs +++ b/Services/ReportExport.cs @@ -61,9 +61,9 @@ internal static string BuildHtml(IEnumerable devices, string subn var sb = new StringBuilder(); sb.AppendLine(""); - sb.AppendLine($""); + sb.AppendLine($""); sb.AppendLine(""); - sb.AppendLine("KillerScan Report"); + sb.AppendLine($"KillerScan - {Esc(L("Str_Filter_Html", "HTML Report"))}"); sb.AppendLine(" + + @@ -374,6 +376,9 @@ + + + @@ -663,6 +668,9 @@ CellStyle="{StaticResource DarkDataGridCell}"> + + + diff --git a/Controls/ScanWorkspace/DemoMode.cs b/Controls/ScanWorkspace/DemoMode.cs index cb89edd9..39aa7ebc 100644 --- a/Controls/ScanWorkspace/DemoMode.cs +++ b/Controls/ScanWorkspace/DemoMode.cs @@ -13,6 +13,7 @@ public partial class ScanWorkspace private void GenerateDemoScan() { var demo = DemoData.Generate(_demoRng); + DemoData.AddIdentification(demo.Devices); SubnetInput.Text = demo.Subnet; GatewayLabel.Text = demo.Gateway; diff --git a/Controls/ScanWorkspace/DeviceActions.cs b/Controls/ScanWorkspace/DeviceActions.cs index b9e17ad4..f196a4da 100644 --- a/Controls/ScanWorkspace/DeviceActions.cs +++ b/Controls/ScanWorkspace/DeviceActions.cs @@ -12,6 +12,17 @@ public partial class ScanWorkspace { private NetworkDevice? GetSelectedDevice() => ResultsGrid.SelectedItem as NetworkDevice; + internal NetworkDevice? EvidenceDevice => _showServices + ? (ServicesGrid.CurrentItem as ServiceRow ?? ServicesGrid.SelectedItem as ServiceRow) is { } row && ServicesGrid.SelectedItems.Contains(row) + ? ActiveDevices.FirstOrDefault(item => item.IpAddress == row.IpAddress) : null + : ResultsGrid.CurrentItem is NetworkDevice current && ResultsGrid.SelectedItems.Contains(current) ? current : GetSelectedDevice(); + + private void Evidence_Click(object sender, RoutedEventArgs e) + { + if (EvidenceDevice is { } device) + new DeviceEvidenceWindow(device) { Owner = Window.GetWindow(this) }.ShowDialog(); + } + private void ResultsGrid_RightClick(object sender, MouseButtonEventArgs e) { var row = ItemsControl.ContainerFromElement(ResultsGrid, e.OriginalSource as DependencyObject) as DataGridRow; @@ -87,7 +98,7 @@ private void SetType_Click(object sender, RoutedEventArgs e) if (sender is MenuItem mi && mi.Tag is string type) { DeviceOverrides.Set(d.MacAddress, type); - d.DeviceType = type; + DeviceIdentification.ApplyManualType(d, type); _filteredView?.Refresh(); if (_showTopology) RefreshTopology(); StatusText.Text = string.Format(Loc("Str_St_OverrideSet"), d.MacAddress, type); @@ -98,7 +109,7 @@ private void ClearOverride_Click(object sender, RoutedEventArgs e) { var d = GetSelectedDevice(); if (d == null || string.IsNullOrEmpty(d.MacAddress)) return; DeviceOverrides.Set(d.MacAddress, null); - d.DeviceType = NetworkScanner.ClassifyDevice(d); + DeviceIdentification.ApplyManualType(d, null); _filteredView?.Refresh(); if (_showTopology) RefreshTopology(); StatusText.Text = string.Format(Loc("Str_St_OverrideCleared"), d.MacAddress); diff --git a/Controls/ScanWorkspace/Export.cs b/Controls/ScanWorkspace/Export.cs index e07631ee..9e3864ac 100644 --- a/Controls/ScanWorkspace/Export.cs +++ b/Controls/ScanWorkspace/Export.cs @@ -134,11 +134,12 @@ private void ExportServicesCsv_Click(object sender, RoutedEventArgs e) // English headers and blanket quoting, matching ReportExport.BuildCsv so both // exports open the same way in a spreadsheet. var text = new StringBuilder(); - text.AppendLine("Service,Port,Name,IP Address,Type,Location ID,Location"); + text.AppendLine("Service,Port,Name,IP Address,Type,Location ID,Location,Identification JSON"); foreach (var row in ServicesGrid.Items.OfType()) text.AppendLine(string.Join(",", ReportExport.CsvField(row.Service), ReportExport.CsvField(row.Port.ToString()), ReportExport.CsvField(row.DeviceName), ReportExport.CsvField(row.IpAddress), ReportExport.CsvField(row.DeviceTypeDisplay), - ReportExport.CsvField(Locations.Current.Id), ReportExport.CsvField(Locations.Current.Name))); + ReportExport.CsvField(Locations.Current.Id), ReportExport.CsvField(Locations.Current.Name), + ReportExport.CsvField(row.Identification == null ? "" : System.Text.Json.JsonSerializer.Serialize(row.Identification)))); File.WriteAllText(dlg.FileName, text.ToString(), Encoding.UTF8); StatusText.Text = string.Format(Loc("Str_St_Exported"), Path.GetFileName(dlg.FileName)); } diff --git a/Controls/ScanWorkspace/ServiceActions.cs b/Controls/ScanWorkspace/ServiceActions.cs index 7f6eb2a0..37386e5a 100644 --- a/Controls/ScanWorkspace/ServiceActions.cs +++ b/Controls/ScanWorkspace/ServiceActions.cs @@ -80,6 +80,7 @@ private void ServiceMenu_Click(object sender, RoutedEventArgs e) if (action.StartsWith("Copy", StringComparison.Ordinal)) CopyServiceText(action); else if (action == "SelectAll") ServicesGrid.SelectAll(); else if (action == "Export") ExportServicesCsv_Click(sender, e); + else if (action == "Evidence") Evidence_Click(sender, e); else RaiseServiceAction(action); } diff --git a/Controls/ScanWorkspace/ServicesView.cs b/Controls/ScanWorkspace/ServicesView.cs index ccab35e1..b995652f 100644 --- a/Controls/ScanWorkspace/ServicesView.cs +++ b/Controls/ScanWorkspace/ServicesView.cs @@ -63,6 +63,7 @@ private sealed class ServiceRow /// the template does its own localization on the way to the screen. /// public string DeviceType { get; init; } = string.Empty; + public DeviceIdentification? Identification { get; init; } /// The localized type, for the CSV export, which writes what you can read. public string DeviceTypeDisplay { get; init; } = string.Empty; public uint IpSortKey { get; init; } @@ -75,6 +76,7 @@ private sealed class ServiceRow Hostname = device.Hostname, IpAddress = device.IpAddress, DeviceType = device.DeviceType, + Identification = device.Identification, DeviceTypeDisplay = Controls.DeviceTypeConverter.Display(device.DeviceType), IpSortKey = device.IpSortKey }; diff --git a/Controls/ScanWorkspace/Shortcuts.cs b/Controls/ScanWorkspace/Shortcuts.cs index de3654d7..5a8f7696 100644 --- a/Controls/ScanWorkspace/Shortcuts.cs +++ b/Controls/ScanWorkspace/Shortcuts.cs @@ -53,7 +53,8 @@ public void HandleKey(KeyEventArgs e) ResultsGrid.ContextMenu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom; ResultsGrid.ContextMenu.IsOpen = true; e.Handled = true; return; } - if (!text && modifiers == ModifierKeys.None && key == Key.F2) action = () => RenameDevice_Click(this, new RoutedEventArgs()); + if (!text && modifiers == ModifierKeys.Control && key == Key.I) action = () => Evidence_Click(this, new RoutedEventArgs()); + else if (!text && modifiers == ModifierKeys.None && key == Key.F2) action = () => RenameDevice_Click(this, new RoutedEventArgs()); else if (!text && modifiers == (ModifierKeys.Control | ModifierKeys.Shift) && key == Key.B) action = () => TrustDevice_Click(this, new RoutedEventArgs()); else if (!text && modifiers == (ModifierKeys.Control | ModifierKeys.Alt) && key == Key.N) action = () => ClearDeviceName_Click(this, new RoutedEventArgs()); else if (!text && modifiers == (ModifierKeys.Control | ModifierKeys.Alt) && key == Key.T) action = () => ClearOverride_Click(this, new RoutedEventArgs()); diff --git a/Engine/Models/DeviceIdentification.cs b/Engine/Models/DeviceIdentification.cs new file mode 100644 index 00000000..63da3570 --- /dev/null +++ b/Engine/Models/DeviceIdentification.cs @@ -0,0 +1,136 @@ +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace KillerScan.Engine; + +public sealed record RecognitionEvidence +{ + public string Source { get; init; } = ""; + public string Value { get; init; } = ""; + public string Origin { get; init; } = ""; + public DateTimeOffset? ObservedAtUtc { get; init; } + public bool Reused { get; init; } +} + +public sealed record RecognitionRule(string Candidate, int Weight, string[] Sources); +public sealed record RecognitionCandidate(string Type, int Score, string[] Sources); + +/// Heuristic assessment, not a calibrated probability or authenticated hardware identity. +public sealed record DeviceIdentification +{ + public int Version { get; init; } = 1; + public string AutomaticType { get; init; } = "Unknown"; + public string Confidence { get; init; } = "None"; + public string? ManualType { get; init; } + public string? ReportedModel { get; init; } + public bool ModelConflict { get; init; } + public bool ConflictingSignals { get; init; } + public DateTimeOffset AssessedAtUtc { get; init; } + public DateTimeOffset? ObservationStartedAtUtc { get; init; } + public DateTimeOffset? ObservationCompletedAtUtc { get; init; } + public string Scope { get; init; } = "external"; + public string[] DisabledFeatures { get; init; } = []; + public RecognitionEvidence[] Evidence { get; init; } = []; + public RecognitionRule[] Rules { get; init; } = []; + public RecognitionCandidate[] Candidates { get; init; } = []; + [JsonIgnore] public string EffectiveConfidence => ManualType == null ? Confidence : "Manual"; + + public DeviceIdentification Snapshot() => this with + { + DisabledFeatures = [.. DisabledFeatures], Evidence = [.. Evidence], + Rules = Rules.Select(rule => rule with { Sources = [.. rule.Sources] }).ToArray(), + Candidates = Candidates.Select(candidate => candidate with { Sources = [.. candidate.Sources] }).ToArray() + }; + + public static void ApplyManualType(NetworkDevice device, string? type) + { + var result = device.Identification ?? new DeviceIdentification { Scope = "manual", AssessedAtUtc = DateTimeOffset.UtcNow }; + device.Identification = result with { ManualType = type }; + device.DeviceType = type ?? result.AutomaticType; + } +} + +internal sealed class RecognitionAssessment +{ + private readonly NetworkDevice _device; + private readonly string? _manual; + private readonly List _evidence = []; + private readonly List _rules = []; + internal RecognitionAssessment(NetworkDevice device, string? manual) + { + _device = device; _manual = manual; + AddFact("Name", device.Hostname, device.HostnameSource == "mDNS" ? device.MdnsObservedAtUtc : device.ObservationCompletedAtUtc, + device.HostnameSource == "mDNS" && device.RecognitionScope == "deep", device.HostnameSource); + AddFact("OUI", device.Vendor, null, true); // A database lookup, not a live model announcement. + AddFact("TCP", string.Join(", ", device.OpenPorts.OrderBy(port => port))); + if (!string.IsNullOrWhiteSpace(device.HttpTitle)) AddFact("HTTP", "title=" + Bounded(device.HttpTitle)); + if (!string.IsNullOrWhiteSpace(device.HttpServer)) AddFact("HTTP", "Server=" + Bounded(device.HttpServer)); + AddFact("TLS", device.TlsSubject); AddFact("SSH", device.SshBanner); + AddFact("SNMP", device.SnmpDescr); AddFact("NetBIOS", device.NetbiosName); + foreach (string service in device.MdnsServices.Take(16).Distinct()) + AddFact("mDNS", Bounded(service), device.MdnsObservedAtUtc, device.RecognitionScope == "deep"); + AddFact("SSDP", device.SsdpServer, device.SsdpObservedAtUtc, device.RecognitionScope == "deep"); + AddFact("ICMP", device.Ttl?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? ""); + } + + internal static string Bounded(string? value) => new((value ?? "").Take(480).Select(c => char.IsControl(c) ? ' ' : c).ToArray()); + private void AddFact(string source, string value) => AddFact(source, value, _device.ObservationCompletedAtUtc, false); + private void AddFact(string source, string value, DateTimeOffset? observed, bool reused, string origin = "") + { + if (!string.IsNullOrWhiteSpace(value)) _evidence.Add(new RecognitionEvidence + { Source = source, Origin = origin, Value = new string(value.Take(512).Select(c => char.IsControl(c) ? ' ' : c).ToArray()), ObservedAtUtc = observed, Reused = reused }); + } + internal void Add(string type, int weight, params string[] sources) + { + type = type == "Network" ? "Switch/AP" : type; + var present = sources.Distinct().Where(source => _evidence.Any(fact => fact.Source == source)).ToArray(); + if (present.Length != 0) _rules.Add(new RecognitionRule(type, weight, present)); + } + internal string Finish(string? routeRole = null) + { + // Repeated tests of the same protocol/vendor do not create independent corroboration. + var candidates = _rules.GroupBy(rule => rule.Candidate).Select(group => + { + var bySource = group.SelectMany(rule => rule.Sources.Select(source => (Source: source, rule.Weight))) + .GroupBy(item => item.Source).ToArray(); + return new RecognitionCandidate(group.Key, bySource.Sum(items => Math.Min(15, items.Max(item => item.Weight))), + bySource.Select(items => items.Key).ToArray()); + }).OrderByDescending(item => item.Score).ThenBy(item => item.Type, StringComparer.Ordinal).ToArray(); + var winner = candidates.FirstOrDefault(item => item.Score >= 6); + bool conflict = winner != null && candidates.Skip(1).Any(item => item.Score >= 6 && + (winner.Score - item.Score <= 5 || _rules.Any(rule => rule.Candidate == item.Type && rule.Weight >= 10 && + rule.Sources.Any(source => source is "HTTP" or "TLS" or "SSH" or "SNMP" or "mDNS" or "SSDP")))); + string automatic = routeRole ?? winner?.Type ?? "Unknown"; + string confidence = "None"; + if (routeRole != null) confidence = "Medium"; // Configured role, not hardware identification. + else if (winner != null) + { + int protocols = winner.Sources.Count(source => (source is "HTTP" or "TLS" or "SSH" or "SNMP" or "mDNS" or "SSDP") && + _evidence.Any(fact => fact.Source == source && !fact.Reused)); + bool specific = _rules.Any(rule => rule.Candidate == winner.Type && rule.Weight >= 10 && + rule.Sources.Any(source => source is "HTTP" or "TLS" or "SSH" or "SNMP" or "mDNS" or "SSDP")); + confidence = !conflict && specific ? protocols >= 2 ? "High" : "Medium" : "Low"; + } + // Accept explicit model declarations only. Never guess a model from an OUI or port. + var models = _evidence.Where(fact => fact.Source is "SNMP" or "HTTP" or "SSDP") + .SelectMany(fact => Regex.Matches(fact.Value, @"\bmodel(?:name)?\s*[:=]\s*([\p{L}\p{N}][^|;,]{0,63})(?=[|;,]|$)", RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)).Cast().Select(match => match.Groups[1].Value.Trim())) + .Where(value => value.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + _device.Identification = new DeviceIdentification + { + AutomaticType = automatic, Confidence = confidence, ManualType = _manual, + ReportedModel = models.Length == 1 ? models[0] : null, ModelConflict = models.Length > 1, + ConflictingSignals = conflict, AssessedAtUtc = DateTimeOffset.UtcNow, + ObservationStartedAtUtc = _device.ObservationStartedAtUtc, ObservationCompletedAtUtc = _device.ObservationCompletedAtUtc, + Scope = _device.RecognitionScope, DisabledFeatures = _device.RecognitionDisabledFeatures, + Evidence = _evidence.ToArray(), Rules = _rules.ToArray(), Candidates = candidates + }; + return _manual ?? automatic; + } + internal void Route(string gateway, string dns) => AddFact("Route", gateway + " / DNS " + dns); + internal void Vendor(string vendor) + { + _evidence.RemoveAll(fact => fact.Source == "OUI"); + AddFact("OUI", vendor, null, true); + } +} diff --git a/Engine/Models/NetworkDevice.cs b/Engine/Models/NetworkDevice.cs index 29093857..c794c709 100644 --- a/Engine/Models/NetworkDevice.cs +++ b/Engine/Models/NetworkDevice.cs @@ -10,6 +10,15 @@ public class NetworkDevice public string Vendor { get; set; } = string.Empty; public string DeviceType { get; set; } = "Unknown"; [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + public DeviceIdentification? Identification { get; set; } + internal DateTimeOffset? ObservationStartedAtUtc { get; set; } + internal DateTimeOffset? ObservationCompletedAtUtc { get; set; } + internal DateTimeOffset? MdnsObservedAtUtc { get; set; } + internal DateTimeOffset? SsdpObservedAtUtc { get; set; } + internal string RecognitionScope { get; set; } = "external"; + internal string HostnameSource { get; set; } = ""; + internal string[] RecognitionDisabledFeatures { get; set; } = []; + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] public string? LocationId { get; set; } [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] public string? LocationName { get; set; } diff --git a/Engine/Scanning/NetworkScanner.BoundProbes.cs b/Engine/Scanning/NetworkScanner.BoundProbes.cs index b9b0f6a5..35a72437 100644 --- a/Engine/Scanning/NetworkScanner.BoundProbes.cs +++ b/Engine/Scanning/NetworkScanner.BoundProbes.cs @@ -14,8 +14,14 @@ public partial class NetworkScanner public ScanBinding? Binding => _binding; public NetworkScanner(ScanBinding binding) : this(new ScanProbes()) => _binding = binding ?? throw new ArgumentNullException(nameof(binding)); private ScanProbes ProbesFor(ScanRun run) => _binding == null ? _probes : new BoundProbes(run); - private string ClassifyForScan(NetworkDevice device) => ClassifyDeviceForNetwork(device, - _binding?.Gateway ?? GatewayIp, _binding?.DnsServer ?? DnsIp, ManualTypeResolver ?? ManualTypeLookup); + private string ClassifyForScan(NetworkDevice device, ScanOptions options, string scope) + { + device.ObservationCompletedAtUtc = DateTimeOffset.UtcNow; + device.RecognitionScope = scope; + device.RecognitionDisabledFeatures = ScanOptions.Features.Where(feature => !options.IsEnabled(feature)).ToArray(); + return ClassifyDeviceForNetwork(device, _binding?.Gateway ?? GatewayIp, _binding?.DnsServer ?? DnsIp, + ManualTypeResolver ?? ManualTypeLookup); + } private static async Task ReceiveProbeAsync(UdpClient udp, IPAddress target, int port, ScanRun? run) { diff --git a/Engine/Scanning/NetworkScanner.cs b/Engine/Scanning/NetworkScanner.cs index 48c41a5c..3dd716a9 100644 --- a/Engine/Scanning/NetworkScanner.cs +++ b/Engine/Scanning/NetworkScanner.cs @@ -89,6 +89,7 @@ public partial class NetworkScanner // Network-wide service discovery results (collected once per scan, keyed by IP). private Dictionary _mdns = []; private Dictionary _ssdp = []; + private DateTimeOffset? _mdnsObservedAtUtc, _ssdpObservedAtUtc; /// /// Parse a CIDR subnet string into a list of IP addresses. @@ -137,8 +138,8 @@ private async Task> ScanSubnetCoreAsync(string cidr, Cancell ct.ThrowIfCancellationRequested(); if (options.ReverseDns) probes.FlushDns(); // Clear previous enrichment even if a disabled or failed collector returns no data. - _mdns = []; - _ssdp = []; + _mdns = []; _mdnsObservedAtUtc = null; + _ssdp = []; _ssdpObservedAtUtc = null; // ScanTargets handles one or many comma-separated targets (CIDR, single host, or a // range) and de-duplicates overlaps. The UI validates the same string before getting // here, so a parse failure at this point yields an empty list rather than throwing. @@ -260,8 +261,8 @@ private async Task> ScanSubnetCoreAsync(string cidr, Cancell total = sortedHosts.Count; // Collect the mDNS/SSDP results before probing (best-effort; empty maps on failure). - try { _mdns = await mdnsTask; } catch (NetworkBindingException) { throw; } catch { } - try { _ssdp = await ssdpTask; } catch (NetworkBindingException) { throw; } catch { } + try { _mdns = await mdnsTask; _mdnsObservedAtUtc = DateTimeOffset.UtcNow; } catch (NetworkBindingException) { throw; } catch { } + try { _ssdp = await ssdpTask; _ssdpObservedAtUtc = DateTimeOffset.UtcNow; } catch (NetworkBindingException) { throw; } catch { } if (fullScan) { @@ -297,8 +298,10 @@ private async Task> ScanSubnetCoreAsync(string cidr, Cancell { var device = new NetworkDevice { + ObservationStartedAtUtc = DateTimeOffset.UtcNow, IpAddress = entry.Addr.ToString(), MacAddress = entry.Mac, + HostnameSource = options.ReverseDns ? "DNS" : "", Hostname = options.ReverseDns ? await AwaitWithCancellation(probes.Hostname(entry.Addr), ct) : string.Empty }; @@ -308,7 +311,7 @@ private async Task> ScanSubnetCoreAsync(string cidr, Cancell ct.ThrowIfCancellationRequested(); // Classify even in quick scan (hostname + OUI, no ports) - device.DeviceType = ClassifyForScan(device); + device.DeviceType = ClassifyForScan(device, options, "quick"); (DeviceFinalizer ?? DeviceCompleted)?.Invoke(device); DeviceFound?.Invoke(device); @@ -384,12 +387,13 @@ private async Task ProbeHostAsync(IPAddress addr, string cachedMa token.ThrowIfCancellationRequested(); var device = new NetworkDevice { + ObservationStartedAtUtc = DateTimeOffset.UtcNow, IpAddress = addr.ToString(), MacAddress = cachedMac }; var dnsTask = options.ReverseDns ? ResolveName() : Task.CompletedTask; - async Task ResolveName() => device.Hostname = await probes.Hostname(addr); + async Task ResolveName() { device.Hostname = await probes.Hostname(addr); if (device.Hostname.Length > 0) device.HostnameSource = "DNS"; } var ttlTask = options.Icmp ? ResolveTtl() : Task.CompletedTask; async Task ResolveTtl() => device.Ttl = await probes.Ping(addr, 400); @@ -411,12 +415,12 @@ private async Task ProbeHostAsync(IPAddress addr, string cachedMa // Attach network-wide discovery results + name fallback (reverse DNS -> mDNS .local name). if (options.Mdns && _mdns.TryGetValue(device.IpAddress, out var md)) { - device.MdnsServices = [.. md.Services]; + device.MdnsServices = [.. md.Services]; device.MdnsObservedAtUtc = _mdnsObservedAtUtc; if (string.IsNullOrEmpty(device.Hostname) && !string.IsNullOrEmpty(md.Name)) - device.Hostname = md.Name; + { device.Hostname = md.Name; device.HostnameSource = "mDNS"; } } if (options.Ssdp && _ssdp.TryGetValue(device.IpAddress, out var srv)) - device.SsdpServer = srv; + { device.SsdpServer = srv; device.SsdpObservedAtUtc = _ssdpObservedAtUtc; } // -- Fingerprint probes: run in parallel, each is gated on relevant open ports -- var fpTasks = new List(); @@ -434,10 +438,10 @@ private async Task ProbeHostAsync(IPAddress addr, string cachedMa // Last-resort name: NetBIOS computer name when reverse DNS and mDNS both came up empty. if (string.IsNullOrEmpty(device.Hostname) && !string.IsNullOrEmpty(device.NetbiosName)) - device.Hostname = device.NetbiosName; + { device.Hostname = device.NetbiosName; device.HostnameSource = "NetBIOS"; } // Classify device type using weighted scoring over all signals. - device.DeviceType = ClassifyForScan(device); + device.DeviceType = ClassifyForScan(device, options, "full"); (DeviceFinalizer ?? DeviceCompleted)?.Invoke(device); return device; @@ -474,13 +478,14 @@ private async Task DeepProbeCoreAsync(string ip, CancellationToke run.CheckTarget(addr); var device = new NetworkDevice { + ObservationStartedAtUtc = DateTimeOffset.UtcNow, IpAddress = ip, // Fresh MAC via ARP. MacAddress = options.Arp ? await AwaitWithCancellation(Task.Run(() => probes.ResolveMac(addr), ct), ct) : string.Empty }; var dnsTask = options.ReverseDns ? ResolveName() : Task.CompletedTask; - async Task ResolveName() => device.Hostname = await probes.Hostname(addr); + async Task ResolveName() { device.Hostname = await probes.Hostname(addr); if (device.Hostname.Length > 0) device.HostnameSource = "DNS"; } var ttlTask = options.Icmp ? ResolveTtl() : Task.CompletedTask; async Task ResolveTtl() => device.Ttl = await probes.Ping(addr, 800); @@ -510,12 +515,12 @@ private async Task DeepProbeCoreAsync(string ip, CancellationToke // Reuse the last full scan's network-wide multicast results for naming. if (options.Mdns && _mdns.TryGetValue(ip, out var md)) { - device.MdnsServices = [.. md.Services]; + device.MdnsServices = [.. md.Services]; device.MdnsObservedAtUtc = _mdnsObservedAtUtc; if (string.IsNullOrEmpty(device.Hostname) && !string.IsNullOrEmpty(md.Name)) - device.Hostname = md.Name; + { device.Hostname = md.Name; device.HostnameSource = "mDNS"; } } if (options.Ssdp && _ssdp.TryGetValue(ip, out var srv)) - device.SsdpServer = srv; + { device.SsdpServer = srv; device.SsdpObservedAtUtc = _ssdpObservedAtUtc; } // Full fingerprint pass. Deep mode widens HTTP/TLS to every open port (not just the // standard web/TLS ones), so services on non-standard ports still get fingerprinted. @@ -533,9 +538,9 @@ private async Task DeepProbeCoreAsync(string ip, CancellationToke ct.ThrowIfCancellationRequested(); if (string.IsNullOrEmpty(device.Hostname) && !string.IsNullOrEmpty(device.NetbiosName)) - device.Hostname = device.NetbiosName; + { device.Hostname = device.NetbiosName; device.HostnameSource = "NetBIOS"; } - device.DeviceType = ClassifyForScan(device); + device.DeviceType = ClassifyForScan(device, options, "deep"); (DeviceFinalizer ?? DeviceCompleted)?.Invoke(device); return device; } @@ -670,7 +675,7 @@ private static string ResolveVendor(string mac) /// /// Weighted-score classifier. Each signal contributes points to candidate /// device types; the highest-scoring type above the threshold wins. - /// Hostname and manual overrides short-circuit before scoring. + /// Records source evidence; names are weak hints and manual assignments remain separate. /// /// Signal strength rough guide: /// 15+ strong positive (protocol banner, TLS cert, specific HTTP title) @@ -681,22 +686,28 @@ private static string ResolveVendor(string mac) public static string ClassifyDevice(NetworkDevice device) => ClassifyDeviceForNetwork(device, GatewayIp, DnsIp, ManualTypeLookup); + /// Assess supplied observations without probing, global routing hints or saved manual preferences. + public static DeviceIdentification IdentifyDevice(NetworkDevice device) + { + device.DeviceType = ClassifyDeviceForNetwork(device, "", "", null); + return device.Identification!; + } + internal static string ClassifyDeviceForNetwork(NetworkDevice device, string gatewayIp, string dnsIp, Func? manualLookup) { // 1. Manual override always wins. var manual = manualLookup?.Invoke(device.MacAddress); - if (manual != null) - return manual; + var assessment = new RecognitionAssessment(device, manual); var ports = device.OpenPorts; - string host = device.Hostname.ToLowerInvariant(); - string title = device.HttpTitle.ToLowerInvariant(); - string server = device.HttpServer.ToLowerInvariant(); - string tls = device.TlsSubject.ToLowerInvariant(); - string ssh = device.SshBanner.ToLowerInvariant(); - string snmp = device.SnmpDescr.ToLowerInvariant(); - string nbName = device.NetbiosName.ToLowerInvariant(); - string ssdp = device.SsdpServer.ToLowerInvariant(); + string host = RecognitionAssessment.Bounded(device.Hostname).ToLowerInvariant(); + string title = RecognitionAssessment.Bounded(device.HttpTitle).ToLowerInvariant(); + string server = RecognitionAssessment.Bounded(device.HttpServer).ToLowerInvariant(); + string tls = RecognitionAssessment.Bounded(device.TlsSubject).ToLowerInvariant(); + string ssh = RecognitionAssessment.Bounded(device.SshBanner).ToLowerInvariant(); + string snmp = RecognitionAssessment.Bounded(device.SnmpDescr).ToLowerInvariant(); + string nbName = RecognitionAssessment.Bounded(device.NetbiosName).ToLowerInvariant(); + string ssdp = RecognitionAssessment.Bounded(device.SsdpServer).ToLowerInvariant(); // 1b. The default gateway is the router. Surface that role (plus DNS when it serves it), // ahead of the port heuristics that would otherwise label it just "DNS Server". @@ -705,70 +716,65 @@ internal static string ClassifyDeviceForNetwork(NetworkDevice device, string gat // Only "Router/DNS" when the gateway IS the configured DNS server. A router that just // has port 53 open (forwarding) but points DNS at a Pi-hole stays plain "Router". bool isDnsServer = !string.IsNullOrEmpty(dnsIp) && device.IpAddress == dnsIp; - return isDnsServer ? "Router/DNS" : "Router"; + assessment.Route(gatewayIp, dnsIp); return assessment.Finish(isDnsServer ? "Router/DNS" : "Router"); } - // 2. Hostname keyword short-circuit (explicit user-set suffix beats scoring). + // 2. Names are weak hints, not a reason to ignore contradictory protocol evidence. foreach (var (pattern, type) in HostnameKeywords) { if (host.Contains(pattern)) - return type; + assessment.Add(type, 6, "Name"); } // 3. OUI vendor with known-bad corrections. - string vendor = device.Vendor.ToLowerInvariant(); + string vendor = RecognitionAssessment.Bounded(device.Vendor).ToLowerInvariant(); if (!string.IsNullOrEmpty(device.MacAddress) && device.MacAddress.Length >= 8) { string prefix = device.MacAddress[..8].ToUpperInvariant(); if (OuiBadMap.TryGetValue(prefix, out var corrected)) - vendor = corrected.ToLowerInvariant(); + { vendor = corrected.ToLowerInvariant(); assessment.Vendor(corrected); } } - // 4. Score candidate types from all signals. - var scores = new Dictionary(); - void Add(string type, int s) - { - if (scores.ContainsKey(type)) - scores[type] += s; - else - scores[type] = s; - } + // 4. Score candidate types from observed signals. + void Add(string type, int weight, params string[] sources) => assessment.Add(type, weight, sources); bool hasWorkstationPorts = ports.Contains(3389) || ports.Contains(445); bool hasPrintProtocol = ports.Contains(9100) || ports.Contains(515) || ports.Contains(631); bool hasActiveDirectoryPorts = ports.Contains(88) && ports.Contains(389) && ports.Contains(445); // -- Hypervisor -- - if (ports.Contains(8006)) Add("Hypervisor", 15); - if (title.Contains("proxmox")) Add("Hypervisor", 15); - if (tls.Contains("vmware") || title.Contains("vmware esxi")) Add("Hypervisor", 15); - if (ports.Contains(902) && ports.Contains(443) && !hasWorkstationPorts) Add("Hypervisor", 10); - if (ports.Contains(2179) && (ports.Contains(5985) || ports.Contains(5986)) && !hasWorkstationPorts) Add("Hypervisor", 8); - if (tls.Contains("xenserver") || title.Contains("xenserver")) Add("Hypervisor", 15); + if (ports.Contains(8006)) Add("Hypervisor", 15, "TCP"); + if (title.Contains("proxmox")) Add("Hypervisor", 15, "HTTP"); + if (tls.Contains("vmware")) Add("Hypervisor", 15, "TLS"); + if (title.Contains("vmware esxi")) Add("Hypervisor", 15, "HTTP"); + if (ports.Contains(902) && ports.Contains(443) && !hasWorkstationPorts) Add("Hypervisor", 10, "TCP"); + if (ports.Contains(2179) && (ports.Contains(5985) || ports.Contains(5986)) && !hasWorkstationPorts) Add("Hypervisor", 8, "TCP"); + if (tls.Contains("xenserver")) Add("Hypervisor", 15, "TLS"); + if (title.Contains("xenserver")) Add("Hypervisor", 15, "HTTP"); // -- Windows workstation -- - if (ports.Contains(3389) && ports.Contains(445)) Add("Windows", 6); - if (server.Contains("microsoft-iis")) Add("Windows", 6); - if (!string.IsNullOrEmpty(nbName) && ports.Contains(445)) Add("Windows", 4); - if (device.Ttl is >= 120 and <= 128 && ports.Contains(445)) Add("Windows", 3); + if (ports.Contains(3389) && ports.Contains(445)) Add("Windows", 6, "TCP"); + if (server.Contains("microsoft-iis")) Add("Windows", 6, "HTTP"); + if (!string.IsNullOrEmpty(nbName) && ports.Contains(445)) Add("Windows", 4, "NetBIOS", "TCP"); + if (device.Ttl is >= 120 and <= 128 && ports.Contains(445)) Add("Windows", 3, "TCP", "ICMP"); // -- Windows Server -- if (ports.Contains(3389) && ports.Contains(445) && (ports.Contains(80) || ports.Contains(443) || ports.Contains(53))) - Add("Windows Server", 6); - if (hasActiveDirectoryPorts) Add("Windows Server", 20); - if (ports.Contains(53) && ports.Contains(88) && ports.Contains(389)) Add("Windows Server", 16); + Add("Windows Server", 6, "TCP"); + if (hasActiveDirectoryPorts) Add("Windows Server", 20, "TCP"); + if (ports.Contains(53) && ports.Contains(88) && ports.Contains(389)) Add("Windows Server", 16, "TCP"); if (ports.Contains(445) && (ports.Contains(636) || ports.Contains(3268) || ports.Contains(3269))) - Add("Windows Server", 14); - if (ports.Contains(135) && ports.Contains(445) && ports.Contains(3389)) Add("Windows Server", 8); - if (title.Contains("exchange") || server.Contains("exchange")) Add("Windows Server", 10); - if (snmp.Contains("windows server")) Add("Windows Server", 15); + Add("Windows Server", 14, "TCP"); + if (ports.Contains(135) && ports.Contains(445) && ports.Contains(3389)) Add("Windows Server", 8, "TCP"); + if (title.Contains("exchange") || server.Contains("exchange")) Add("Windows Server", 10, "HTTP"); + if (snmp.Contains("windows server")) Add("Windows Server", 15, "SNMP"); // -- Linux / SSH -- // Windows boxes with OpenSSH Server advertise "OpenSSH_for_Windows" -- score Windows, not Linux. bool sshIsWindows = ssh.Contains("for_windows"); - if (sshIsWindows) Add("Windows", 15); - if (!sshIsWindows && (ssh.StartsWith("ssh-2.0-openssh") || ssh.StartsWith("ssh-1.99-openssh"))) Add("Linux/SSH", 8); - if (device.Ttl is >= 60 and <= 64 && ports.Contains(22) && !sshIsWindows) Add("Linux/SSH", 3); + if (sshIsWindows) Add("Windows", 15, "SSH"); + if (!sshIsWindows && (ssh.StartsWith("ssh-2.0-openssh") || ssh.StartsWith("ssh-1.99-openssh"))) Add("Linux/SSH", 8, "SSH"); + if (device.Ttl is >= 60 and <= 64 && ports.Contains(22) && !sshIsWindows) Add("Linux/SSH", 3, "TCP", "ICMP"); // -- Network gear (switches, routers, firewalls, APs) -- bool isNetworkVendor = vendor.Contains("cisco") || vendor.Contains("ubiquiti") @@ -777,66 +783,56 @@ void Add(string type, int s) || vendor.Contains("juniper") || vendor.Contains("mikrotik") || vendor.Contains("gl technologies") || vendor.Contains("gl.inet") || vendor.Contains("draytek") || vendor.Contains("zyxel") || vendor.Contains("linksys") || vendor.Contains("sonicwall") || vendor.Contains("watchguard"); - if (isNetworkVendor) Add("Network", 8); - if (ssh.Contains("cisco") || ssh.Contains("routeros") || ssh.Contains("mikrotik")) Add("Network", 15); + if (isNetworkVendor) Add("Network", 8, "OUI"); + if (ssh.Contains("cisco") || ssh.Contains("routeros") || ssh.Contains("mikrotik")) Add("Network", 15, "SSH"); if (title.Contains("unifi") || title.Contains("fortigate") || title.Contains("sonicwall") - || title.Contains("pfsense") || title.Contains("opnsense") || title.Contains("mikrotik")) Add("Network", 15); - if (snmp.Contains("cisco ios") || snmp.Contains("juniper") || snmp.Contains("fortigate")) Add("Network", 12); + || title.Contains("pfsense") || title.Contains("opnsense") || title.Contains("mikrotik")) Add("Network", 15, "HTTP"); + if (snmp.Contains("cisco ios") || snmp.Contains("juniper") || snmp.Contains("fortigate")) Add("Network", 12, "SNMP"); // Router vs Switch/AP disambiguation within network gear. - if (isNetworkVendor && ports.Contains(53)) Add("Router", 10); - if (isNetworkVendor && ports.Contains(161) && !ports.Contains(53)) Add("Switch/AP", 8); + if (isNetworkVendor && ports.Contains(53)) Add("Router", 10, "TCP", "OUI"); + if (isNetworkVendor && ports.Contains(161) && !ports.Contains(53)) Add("Switch/AP", 8, "TCP", "OUI"); // -- Printer -- bool isPrinterVendor = vendor.Contains("canon") || vendor.Contains("epson") || vendor.Contains("brother") || vendor.Contains("xerox") || vendor.Contains("lexmark") || vendor.Contains("ricoh") || vendor.Contains("konica") || vendor.Contains("kyocera"); // HP vendor alone is ambiguous (laptops, servers, printers). Only trust with printer ports. - if (ports.Contains(9100)) Add("Printer", 10); - if (!hasActiveDirectoryPorts && (ports.Contains(515) || ports.Contains(631))) Add("Printer", 4); - if (isPrinterVendor && hasPrintProtocol) Add("Printer", 12); - if (isPrinterVendor && ports.Count <= 3) Add("Printer", 6); - if (vendor.Contains("hewlett packard") && ports.Contains(9100)) Add("Printer", 12); - if (snmp.Contains("laserjet") || snmp.Contains("officejet") || snmp.Contains("printer")) Add("Printer", 15); - if (title.Contains("embedded web server") || title.Contains("web image monitor")) Add("Printer", 10); + if (ports.Contains(9100)) Add("Printer", 10, "TCP"); + if (!hasActiveDirectoryPorts && (ports.Contains(515) || ports.Contains(631))) Add("Printer", 4, "TCP"); + if (isPrinterVendor && hasPrintProtocol) Add("Printer", 12, "TCP", "OUI"); + if (isPrinterVendor && ports.Count <= 3) Add("Printer", 6, "OUI"); + if (vendor.Contains("hewlett packard") && ports.Contains(9100)) Add("Printer", 12, "TCP", "OUI"); + if (snmp.Contains("laserjet") || snmp.Contains("officejet") || snmp.Contains("printer")) Add("Printer", 15, "SNMP"); + if (title.Contains("embedded web server") || title.Contains("web image monitor")) Add("Printer", 10, "HTTP"); // -- NAS -- if (vendor.Contains("synology") || vendor.Contains("qnap") || vendor.Contains("asustor") || vendor.Contains("drobo") || vendor.Contains("buffalo") || vendor.Contains("terramaster")) - Add("NAS", 12); + Add("NAS", 12, "OUI"); if (title.Contains("diskstation") || title.Contains("synology") || title.Contains("dsm ")) - Add("NAS", 15); + Add("NAS", 15, "HTTP"); if (title.Contains("qts") || title.Contains("qnap") || title.Contains("truenas") || title.Contains("freenas")) - Add("NAS", 15); - if (ports.Contains(548)) Add("NAS", 4); + Add("NAS", 15, "HTTP"); + if (ports.Contains(548)) Add("NAS", 4, "TCP"); // -- Apple iDevice (iPhone / iPad) -- bool isApple = vendor.Contains("apple"); // Port 62078 is the Apple iDevice sync/tether port -- exclusively iPhones and iPads. - // Score regardless of OUI so randomized-MAC iDevices are still caught. - if (ports.Contains(62078)) Add("iPhone", 15); - if (isApple && ports.Contains(62078)) Add("iPhone", 5); // OUI bonus - if (isApple && ports.Count <= 2) Add("iPhone", 6); - if (device.MdnsServices.Any(s => s.Contains("_airplay") || s.Contains("_raop") || s.Contains("_airport"))) - Add("Apple Device", 10); - - // -- Android / Mobile -- - bool isMobileVendor = vendor.Contains("samsung") || vendor.Contains("oneplus") - || vendor.Contains("xiaomi") || vendor.Contains("huawei") || vendor.Contains("motorola") - || vendor.Contains("oppo") || vendor.Contains("vivo") || vendor.Contains("zte") - || vendor.Contains("lg electronics") || vendor.Contains("google") - || vendor.Contains("bbk electronics") || vendor.Contains("realme") - || vendor.Contains("nothing technology") || vendor.Contains("fairphone"); - if (isMobileVendor && ports.Count <= 3) Add("Android", 8); + // Score regardless of OUI so randomized-MAC devices can still be suggested. + if (ports.Contains(62078)) Add("iPhone", 15, "TCP"); + if (isApple && ports.Contains(62078)) Add("iPhone", 5, "TCP", "OUI"); // OUI bonus + if (device.MdnsServices.Take(16).Select(value => RecognitionAssessment.Bounded(value).ToLowerInvariant()).Any(s => s.Contains("_airplay") || s.Contains("_raop") || s.Contains("_airport"))) + Add("Apple Device", 10, "mDNS"); // -- Surveillance camera -- if (vendor.Contains("hikvision") || vendor.Contains("dahua") || vendor.Contains("axis") || vendor.Contains("amcrest") || vendor.Contains("reolink") || vendor.Contains("foscam")) - Add("Camera", 12); + Add("Camera", 12, "OUI"); if (title.Contains("hikvision") || title.Contains("dahua") || title.Contains("camera") || title.Contains("dvr") || title.Contains("nvr") || title.Contains("ipcam")) - Add("Camera", 12); - if (ports.Contains(554)) Add("Camera", 4); + Add("Camera", 12, "HTTP"); + if (ports.Contains(554)) Add("Camera", 4, "TCP"); // -- IoT / smart home -- if (vendor.Contains("espressif") || vendor.Contains("tuya") || vendor.Contains("sonoff") @@ -844,78 +840,41 @@ void Add(string type, int s) || vendor.Contains("signify") || vendor.Contains("lutron") || vendor.Contains("wemo") || vendor.Contains("wyze") || vendor.Contains("aqara") || vendor.Contains("linkplay") || vendor.Contains("wiim") || vendor.Contains("govee")) - Add("IoT", 10); + Add("IoT", 10, "OUI"); // -- Home Assistant -- - if (ports.Contains(8123)) Add("Home Assistant", 12); - if (title.Contains("home assistant") || host.Contains("homeassistant") || host.Contains("home-assistant")) - Add("Home Assistant", 15); + if (ports.Contains(8123)) Add("Home Assistant", 12, "TCP"); + if (title.Contains("home assistant")) Add("Home Assistant", 15, "HTTP"); + if (host.Contains("homeassistant") || host.Contains("home-assistant")) Add("Home Assistant", 6, "Name"); // -- DNS server (Pi-hole, AdGuard, BIND) -- - if (title.Contains("pi-hole") || title.Contains("pihole")) Add("DNS Server", 15); - if (title.Contains("adguard")) Add("DNS Server", 15); - if (ports.Contains(53) && ports.Contains(80) && !isNetworkVendor) Add("DNS Server", 6); + if (title.Contains("pi-hole") || title.Contains("pihole")) Add("DNS Server", 15, "HTTP"); + if (title.Contains("adguard")) Add("DNS Server", 15, "HTTP"); + if (ports.Contains(53) && ports.Contains(80) && !isNetworkVendor) Add("DNS Server", 6, "TCP"); // -- mDNS (Bonjour) service types: the strongest, most specific signals -- - bool HasSvc(string s) => device.MdnsServices.Any(x => x.Contains(s)); - if (HasSvc("_googlecast")) Add("Smart TV", 14); - if (HasSvc("_printer") || HasSvc("_ipp") || HasSvc("_pdl-datastream")) Add("Printer", 14); - if (HasSvc("_sonos") || HasSvc("_spotify-connect")) Add("Media Streamer", 12); - if (HasSvc("_airplay") || HasSvc("_raop")) Add("Media Streamer", 8); - if (HasSvc("_hap") || HasSvc("_homekit") || HasSvc("_hue")) Add("IoT", 10); + bool HasSvc(string s) => device.MdnsServices.Take(16).Select(value => RecognitionAssessment.Bounded(value).ToLowerInvariant()).Any(x => x.Contains(s)); + if (HasSvc("_googlecast")) Add("Smart TV", 14, "mDNS"); + if (HasSvc("_printer") || HasSvc("_ipp") || HasSvc("_pdl-datastream")) Add("Printer", 14, "mDNS"); + if (HasSvc("_sonos") || HasSvc("_spotify-connect")) Add("Media Streamer", 12, "mDNS"); + if (HasSvc("_airplay") || HasSvc("_raop")) Add("Media Streamer", 8, "mDNS"); + if (HasSvc("_hap") || HasSvc("_homekit") || HasSvc("_hue")) Add("IoT", 10, "mDNS"); // -- SSDP / UPnP SERVER string -- - if (ssdp.Contains("roku")) Add("Smart TV", 12); - if (ssdp.Contains("synology") || ssdp.Contains("qnap")) Add("NAS", 12); - if (ssdp.Contains("plex")) Add("Media Streamer", 12); - if (ssdp.Contains("sonos") || ssdp.Contains("dlna") || ssdp.Contains("mediaserver")) Add("Media Streamer", 8); - if (ssdp.Contains("samsung") && ssdp.Contains("tv")) Add("Smart TV", 10); + if (ssdp.Contains("roku")) Add("Smart TV", 12, "SSDP"); + if (ssdp.Contains("synology") || ssdp.Contains("qnap")) Add("NAS", 12, "SSDP"); + if (ssdp.Contains("plex")) Add("Media Streamer", 12, "SSDP"); + if (ssdp.Contains("sonos") || ssdp.Contains("dlna") || ssdp.Contains("mediaserver")) Add("Media Streamer", 8, "SSDP"); + if (ssdp.Contains("samsung") && ssdp.Contains("tv")) Add("Smart TV", 10, "SSDP"); // -- New ports -- - if (ports.Contains(32400)) Add("Media Streamer", 12); // Plex - if (ports.Contains(1883) || ports.Contains(8883)) Add("IoT", 6); // MQTT brokers + if (ports.Contains(32400)) Add("Media Streamer", 12, "TCP"); // Plex + if (ports.Contains(1883) || ports.Contains(8883)) Add("IoT", 6, "TCP"); // MQTT brokers // -- Generic web device (catch-all) -- - if (ports.Contains(80) || ports.Contains(443) || ports.Contains(8080)) Add("Web Device", 2); + if (ports.Contains(80) || ports.Contains(443) || ports.Contains(8080)) Add("Web Device", 2, "TCP"); - // Pick highest-scoring candidate above threshold. - if (scores.Count > 0) - { - var winner = scores.OrderByDescending(kvp => kvp.Value).First(); - if (winner.Value >= 6) - // Generic "Network" gear with no Router/Switch disambiguation is most likely a switch/AP. - return winner.Key == "Network" ? "Switch/AP" : winner.Key; - } - - // Randomized / locally-administered MAC with no responsive ports is almost - // always a phone or tablet using iOS/Android private Wi-Fi addressing. - bool localAdminMac = false; - if (!string.IsNullOrEmpty(device.MacAddress) && device.MacAddress.Length >= 2 - && byte.TryParse(device.MacAddress[..2], System.Globalization.NumberStyles.HexNumber, null, out var firstByte)) - { - localAdminMac = (firstByte & 0x02) != 0; - } - if (localAdminMac && ports.Count <= 3) return "Mobile"; - - // Fallback heuristics when no candidate cleared threshold. - if (ports.Contains(22)) return "Linux/SSH"; - if (ports.Contains(445) || ports.Contains(3389)) return "Windows"; - if (ports.Contains(80) || ports.Contains(443)) return "Web Device"; - // Known PC/workstation makers with no open ports are idle/standby computers, not IoT. - // (A business micro in modern standby still answers ARP but blocks every inbound TCP port.) - bool isPcVendor = vendor.Contains("dell") || vendor.Contains("hewlett") || vendor.Contains("hp inc") - || vendor.Contains("lenovo") || vendor.Contains("micro-star") || vendor.Contains("asustek") - || vendor.Contains("asrock") || vendor.Contains("gigabyte") || vendor.Contains("giga-byte") - || vendor.Contains("framework") || vendor.Contains("fujitsu") || vendor.Contains("acer") - || vendor.Contains("clevo"); - if (ports.Count == 0 && isPcVendor) return "Windows"; - - // Responds to ARP/ping but exposes zero TCP ports -- almost always a - // smart bulb, plug, sensor, or other IoT endpoint. Even with a blank - // OUI (Govee and similar use unregistered prefixes), IoT beats Unknown. - if (ports.Count == 0 && !string.IsNullOrEmpty(device.MacAddress)) return "IoT"; - if (ports.Count == 0) return "Unknown"; - return "Other"; + return assessment.Finish(); } // =================================================================== diff --git a/Features/Cli/CliRunner.cs b/Features/Cli/CliRunner.cs index 8ac79fad..c02f35bc 100644 --- a/Features/Cli/CliRunner.cs +++ b/Features/Cli/CliRunner.cs @@ -546,6 +546,7 @@ private static int RunDevices(List positionals, Dictionary ApplyFilters(List devices, private static string Render(string format, List devices, string label, string theme, bool noHeader, ScanLocation? location = null) => format switch { - "csv" => location == null ? Csv(devices, !noHeader) : ReportExport.BuildLocationCsv(devices, location, !noHeader), + "csv" => ReportExport.BuildLocationCsv(devices, location, !noHeader), "json" => JsonSerializer.Serialize(devices, new JsonSerializerOptions { WriteIndented = true }), "html" => ReportExport.BuildLocationHtml(devices, label, theme.ToLowerInvariant(), location), _ => Table(devices, !noHeader), diff --git a/Modules/KillerScan/KillerScan.psm1 b/Modules/KillerScan/KillerScan.psm1 index a0db3741..c988ffcc 100644 --- a/Modules/KillerScan/KillerScan.psm1 +++ b/Modules/KillerScan/KillerScan.psm1 @@ -24,7 +24,8 @@ function Invoke-KsText { param([string[]]$Arguments) $exe = Get-KsExe - $raw = & $exe @Arguments 2>&1 + # An explicit pipeline also waits for the Windows GUI-subsystem executable. + $raw = & $exe @Arguments 2>&1 | ForEach-Object { $_ } # 2 is bad usage, which is a real error. 1 is "found nothing" and 3 is "unknown # vendor", both of which are answers rather than failures. if ($LASTEXITCODE -eq 2) { throw (($raw | Out-String).Trim()) } @@ -54,6 +55,7 @@ function ConvertTo-KsDevice { Mac = $Device.MacAddress Vendor = $Device.Vendor Type = $Device.DeviceType + Identification = $Device.Identification Ports = @($Device.OpenPorts) Ttl = $Device.Ttl Http = $Device.HttpTitle @@ -112,7 +114,7 @@ function scan { if ($LocationId) { $arguments += @('/location', $LocationId) } if ($Skip) { $arguments += @('/skip', ($Skip -join ',')) } if ($Timeout) { $arguments += @('/timeout', $Timeout) } - $arguments += @('/json', '/quiet') + $arguments += @('/json') Invoke-KsJson $arguments | ForEach-Object { ConvertTo-KsDevice $_ } } @@ -144,7 +146,7 @@ function probe { if ($LocationId) { $arguments += @('/location', $LocationId) } if ($Skip) { $arguments += @('/skip', ($Skip -join ',')) } if ($Timeout) { $arguments += @('/timeout', $Timeout) } - $arguments += @('/json', '/quiet') + $arguments += @('/json') Invoke-KsJson $arguments | ForEach-Object { ConvertTo-KsDevice $_ } } diff --git a/README.md b/README.md index 3f6e0d07..0130626b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ One-click, fast network scanner built for field techs. - mDNS/SSDP service discovery - MAC vendor lookup across the full IEEE OUI registries - Weighted-score device classifier +- [Recognition evidence and confidence](docs/device-identification.md), with observed sources, times, manual provenance and explicitly reported models Single portable EXE. The only requirement is .NET Framework 4.8, which Windows 10 (version 1903 and later) and Windows 11 already include. Free, open-source, GPLv3. diff --git a/Services/DemoData.cs b/Services/DemoData.cs index 61bd6911..76f3f8d9 100644 --- a/Services/DemoData.cs +++ b/Services/DemoData.cs @@ -45,6 +45,20 @@ internal static class DemoData /// internal static DemoScan? Current; + internal static void AddIdentification(IEnumerable devices) + { + foreach (var device in devices) + { + string preset = device.DeviceType; + if (preset == "Printer") + { device.HttpTitle = "Embedded Web Server; model=DemoPrinter42"; device.SnmpDescr = "Printer; model=DemoPrinter42"; } + if (preset == "Hypervisor") + { device.HttpTitle = "VMware ESXi"; device.TlsSubject = "CN=VMware ESXi"; } + NetworkScanner.IdentifyDevice(device); + if (device.DeviceType != preset) DeviceIdentification.ApplyManualType(device, preset); + } + } + private static readonly string[] Subnets = ["10.0.0", "10.10.10", "172.16.4", "192.168.0", "192.168.1", "192.168.50"]; diff --git a/Services/IdentificationText.cs b/Services/IdentificationText.cs new file mode 100644 index 00000000..3c9c708d --- /dev/null +++ b/Services/IdentificationText.cs @@ -0,0 +1,55 @@ +using System.Text; +using System.Windows; +using KillerScan.Engine; + +namespace KillerScan.Services; + +internal static class IdentificationText +{ + internal static string L(string key) => Application.Current?.TryFindResource("Str_Evidence_" + key) as string ?? key switch + { + "Title" => "Recognition evidence", "Model" => "Reported model (unverified)", "Time" => "Observation window (UTC)", + "Automatic" => "Automatic type", "Disabled" => "Disabled probes", "External" => "Imported / unspecified scope", + "Candidates" => "Other candidates", "Cached" => "Earlier / cached", "None" => "Not available", + "Note" => "Heuristic estimate, not a probability. Protocol values are self-reported. MAC vendor does not identify a model; missing responses do not prove absence.", + "Conflict" => "Conflicting signals; check the individual sources.", _ => key + }; + internal static string Confidence(DeviceIdentification? result) => L(result?.EffectiveConfidence is "Low" or "Medium" or "High" or "Manual" + ? result.EffectiveConfidence : "None"); + internal static string Describe(NetworkDevice device) + { + var text = new StringBuilder(); + var result = device.Identification; + text.AppendLine(device.IpAddress + " " + device.Hostname); + text.AppendLine(Controls.DeviceTypeConverter.Display(device.DeviceType) + " - " + L("Confidence") + ": " + Confidence(result)); + if (result == null) return text.AppendLine(L("None")).AppendLine(L("Note")).ToString(); + var winner = result.Candidates.FirstOrDefault(candidate => candidate.Type == result.AutomaticType); + text.AppendLine(L("Automatic") + ": " + Controls.DeviceTypeConverter.Display(result.AutomaticType) + " - " + L(result.Confidence) + + " (" + string.Join(", ", winner?.Sources.Select(SourceName) ?? result.Evidence.Where(fact => fact.Source == "Route").Select(fact => SourceName(fact.Source))) + ")"); + text.AppendLine(L("Model") + ": " + (result.ReportedModel ?? L("None"))); + text.AppendLine(L("Time") + ": " + Time(result.ObservationStartedAtUtc) + " / " + Time(result.ObservationCompletedAtUtc)); + text.AppendLine(L(result.Scope switch { "quick" => "Quick", "full" => "Full", "deep" => "Deep", _ => "External" }) + "; " + L("Disabled") + ": " + result.DisabledFeatures.Length); + if (result.ConflictingSignals || result.ModelConflict) text.AppendLine(L("Conflict")); + text.AppendLine().AppendLine(L("Note")).AppendLine().AppendLine(L("Source") + " / " + L("Value")); + foreach (var fact in result.Evidence) + { + string source = fact.Source == "Name" && fact.Origin.Length > 0 ? fact.Origin : SourceName(fact.Source); + text.AppendLine(source + " " + Time(fact.ObservedAtUtc) + (fact.Reused ? " [" + L("Cached") + "]" : "")); + text.AppendLine(" " + fact.Value); + } + if (result.Candidates.Any(candidate => candidate.Type != result.AutomaticType && candidate.Score >= 6)) + { + text.AppendLine().AppendLine(L("Candidates")); + foreach (var candidate in result.Candidates.Where(candidate => candidate.Type != result.AutomaticType && candidate.Score >= 6)) + text.AppendLine(Controls.DeviceTypeConverter.Display(candidate.Type) + " (" + string.Join(", ", candidate.Sources.Select(SourceName)) + ")"); + } + return text.ToString(); + } + private static string Time(DateTimeOffset? value) => value?.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", System.Globalization.CultureInfo.InvariantCulture) ?? L("None"); + private static string SourceName(string source) => source switch + { + "Name" => Application.Current?.TryFindResource("Str_Col_Host") as string ?? "Hostname", + "Route" => Application.Current?.TryFindResource("Str_Lbl_Gateway") as string ?? "Gateway/DNS", + "OUI" => "MAC/OUI", _ => source + }; +} diff --git a/Services/ReportExport.cs b/Services/ReportExport.cs index c2d9bf62..947e9ec9 100644 --- a/Services/ReportExport.cs +++ b/Services/ReportExport.cs @@ -44,11 +44,13 @@ private static string Ljs(string key, string fallback) => internal static string BuildLocationCsv(IEnumerable devices, ScanLocation? location, bool header = true) { var sb = new StringBuilder(); - if (header) sb.AppendLine("IP Address,Hostname,MAC Address,Vendor,Type,Open Ports" + (location == null ? "" : ",Location ID,Location")); + if (header) sb.AppendLine("IP Address,Hostname,MAC Address,Vendor,Type,Open Ports" + (location == null ? "" : ",Location ID,Location") + ",Recognition confidence,Reported model,Identification JSON"); foreach (var d in devices.OrderBy(d => d.IpSortKey)) sb.AppendLine(string.Join(",", CsvField(d.IpAddress), CsvField(d.Hostname), CsvField(d.MacAddress), CsvField(d.Vendor), CsvField(d.DeviceType), CsvField(d.OpenPortsDisplay)) + - (location == null ? "" : "," + CsvField(location.Id) + "," + CsvField(location.Name))); + (location == null ? "" : "," + CsvField(location.Id) + "," + CsvField(location.Name)) + "," + + CsvField(d.Identification?.EffectiveConfidence) + "," + CsvField(d.Identification?.ReportedModel) + "," + + CsvField(d.Identification == null ? "" : System.Text.Json.JsonSerializer.Serialize(d.Identification))); return sb.ToString(); } @@ -157,7 +159,9 @@ internal static string BuildLocationHtml(IEnumerable devices, str $"{Esc(d.Vendor)}" + // The CLASS above keeps the English slug (that is what the CSS matches); only // the visible cell is translated. - $"{Esc(Controls.DeviceTypeConverter.Display(d.DeviceType))}" + + $"{Esc(Controls.DeviceTypeConverter.Display(d.DeviceType))}" + + $"
{Esc(L("Str_Evidence_Title", "Recognition evidence"))}: {Esc(IdentificationText.Confidence(d.Identification))}" + + $"
{Esc(IdentificationText.Describe(d))}
" + $"{Esc(d.OpenPortsDisplay)}"); } sb.AppendLine(""); diff --git a/Services/ScanHistory.cs b/Services/ScanHistory.cs index ba0f724a..ba9240e5 100644 --- a/Services/ScanHistory.cs +++ b/Services/ScanHistory.cs @@ -24,6 +24,8 @@ internal sealed class HistoricalDevice public string MacAddress { get; set; } = string.Empty; public string Vendor { get; set; } = string.Empty; public string DeviceType { get; set; } = string.Empty; + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + public DeviceIdentification? Identification { get; set; } public List OpenPorts { get; set; } = []; } @@ -151,6 +153,7 @@ private static bool HasChanged(HistoricalDevice before, HistoricalDevice after) MacAddress = device.MacAddress, Vendor = device.Vendor, DeviceType = device.DeviceType, + Identification = device.Identification?.Snapshot(), OpenPorts = [.. device.OpenPorts.OrderBy(port => port)] }; diff --git a/Strings/bn.xaml b/Strings/bn.xaml index 271c1f5b..6021d932 100644 --- a/Strings/bn.xaml +++ b/Strings/bn.xaml @@ -506,4 +506,25 @@ এই নামে একটি অবস্থান ইতিমধ্যেই আছে। অন্য নাম বেছে নিন। এই অবস্থানটি পাওয়া যাচ্ছে না বা বদলানো যাচ্ছে না। বিদ্যমান একটি অবস্থান বেছে নিন। অবস্থানের তথ্য পড়া বা সংরক্ষণ করা যায়নি। চালিয়ে যাওয়ার আগে KillerScan-এর অন্য কপিগুলো বন্ধ করে ফাইলের অনুমতি পরীক্ষা করুন অথবা ব্যাকআপ পুনরুদ্ধার করুন। + শনাক্তকরণের প্রমাণ + নির্ভরযোগ্যতা + পাওয়া যায়নি + কম + মাঝারি + বেশি + ম্যানুয়াল + জানানো মডেল (যাচাই করা হয়নি) + পর্যবেক্ষণের সময়সীমা (UTC) + উৎস + পর্যবেক্ষিত মান + পরস্পরবিরোধী সংকেত; প্রতিটি উৎস পরীক্ষা করুন। + আগের / ক্যাশে থাকা + এটি আনুমানিক মূল্যায়ন, সম্ভাবনা নয়। প্রোটোকলের মান ডিভাইস নিজেই জানায়। MAC নির্মাতা মডেলের প্রমাণ নয়; উত্তর না পাওয়া অনুপস্থিতির প্রমাণ নয়। + স্বয়ংক্রিয় ধরন + বন্ধ থাকা অনুসন্ধান + দ্রুত স্ক্যান + পূর্ণ স্ক্যান + গভীর স্ক্যান + আমদানি করা / পরিসর অনির্দিষ্ট + অন্যান্য সম্ভাব্য ধরন diff --git a/Strings/cs-CZ.xaml b/Strings/cs-CZ.xaml index defb319a..97cf6387 100644 --- a/Strings/cs-CZ.xaml +++ b/Strings/cs-CZ.xaml @@ -504,4 +504,25 @@ Vaše nastavení nebude odstraněno. Lokalita s tímto názvem již existuje. Zvol jiný název. Tato lokalita není dostupná nebo ji nelze změnit. Vyber existující lokalitu. Data lokality nelze přečíst nebo uložit. Před pokračováním zavři ostatní instance KillerScan a zkontroluj oprávnění k souborům nebo obnov zálohu. + Podklady rozpoznání + Spolehlivost + Není k dispozici + Nízká + Střední + Vysoká + Ruční + Hlášený model (neověřený) + Doba pozorování (UTC) + Zdroj + Zjištěná hodnota + Protichůdné údaje; zkontroluj jednotlivé zdroje. + Starší / z mezipaměti + Heuristický odhad, nikoli pravděpodobnost. Hodnoty protokolů hlásí samotné zařízení. Výrobce MAC neurčuje model; chybějící odpovědi nedokazují nepřítomnost. + Automatický typ + Vypnuté dotazy + Rychlé skenování + Úplné skenování + Hloubkové skenování + Importováno / rozsah neurčen + Další kandidáti diff --git a/Strings/de-DE.xaml b/Strings/de-DE.xaml index e6f3011a..545777d4 100644 --- a/Strings/de-DE.xaml +++ b/Strings/de-DE.xaml @@ -507,4 +507,25 @@ Ihre Einstellungen bleiben erhalten. Ein Standort mit diesem Namen existiert bereits. Wähle einen anderen Namen. Dieser Standort ist nicht verfügbar oder kann nicht geändert werden. Wähle einen vorhandenen Standort. Standortdaten konnten nicht gelesen oder gespeichert werden. Schließe andere KillerScan-Instanzen und prüfe die Dateiberechtigungen oder stelle eine Sicherung wieder her, bevor du fortfährst. + Erkennungsgrundlagen + Zuverlässigkeit + Nicht verfügbar + Niedrig + Mittel + Hoch + Manuell + Gemeldetes Modell (ungeprüft) + Erfassungszeitraum (UTC) + Quelle + Beobachteter Wert + Widersprüchliche Hinweise; prüfe die einzelnen Quellen. + Früher / zwischengespeichert + Heuristische Einschätzung, keine Wahrscheinlichkeit. Protokollwerte werden vom Gerät selbst gemeldet. Der MAC-Hersteller belegt kein Modell; fehlende Antworten belegen keine Abwesenheit. + Automatischer Typ + Deaktivierte Abfragen + Schnellscan + Vollständiger Scan + Tiefenscan + Importiert / Umfang nicht angegeben + Weitere Kandidaten diff --git a/Strings/en-US.xaml b/Strings/en-US.xaml index 797afd14..19970a5a 100644 --- a/Strings/en-US.xaml +++ b/Strings/en-US.xaml @@ -570,4 +570,25 @@ Your settings will not be removed. A location with this name already exists. Choose another name. This location is unavailable or cannot be changed. Choose an existing location. Location data could not be read or saved. Close other copies of KillerScan and check file permissions or restore a backup before continuing. + Recognition evidence + Confidence + Not available + Low + Medium + High + Manual + Reported model (unverified) + Observation window (UTC) + Source + Observed value + Conflicting signals; check the individual sources. + Earlier / cached + Heuristic estimate, not a probability. Protocol values are self-reported. MAC vendor does not identify a model; missing responses do not prove absence. + Automatic type + Disabled probes + Quick scan + Full scan + Deep scan + Imported / unspecified scope + Other candidates diff --git a/Strings/es.xaml b/Strings/es.xaml index deb10cc1..343c0de4 100644 --- a/Strings/es.xaml +++ b/Strings/es.xaml @@ -506,4 +506,25 @@ Su configuración no se eliminará. Ya existe una ubicación con este nombre. Elige otro nombre. Esta ubicación no está disponible o no se puede cambiar. Elige una ubicación existente. No se pudieron leer o guardar los datos de la ubicación. Cierra otras instancias de KillerScan y revisa los permisos de los archivos o restaura una copia de seguridad antes de continuar. + Evidencias de identificación + Confianza + No disponible + Baja + Media + Alta + Manual + Modelo comunicado (sin verificar) + Periodo de observación (UTC) + Fuente + Valor observado + Señales contradictorias; revisa las fuentes individuales. + Anterior / en caché + Estimación heurística, no una probabilidad. El propio dispositivo comunica los valores de protocolo. El fabricante MAC no identifica un modelo; la falta de respuestas no demuestra ausencia. + Tipo automático + Consultas desactivadas + Análisis rápido + Análisis completo + Análisis profundo + Importado / alcance sin especificar + Otros candidatos diff --git a/Strings/fr-FR.xaml b/Strings/fr-FR.xaml index f44d248b..4af222f7 100644 --- a/Strings/fr-FR.xaml +++ b/Strings/fr-FR.xaml @@ -506,4 +506,25 @@ Vos paramètres ne seront pas supprimés. Un emplacement porte déjà ce nom. Choisissez un autre nom. Cet emplacement est indisponible ou ne peut pas être modifié. Choisissez un emplacement existant. Impossible de lire ou d'enregistrer les données de l'emplacement. Fermez les autres instances de KillerScan et vérifiez les droits d'accès aux fichiers ou restaurez une sauvegarde avant de continuer. + Éléments d'identification + Fiabilité + Indisponible + Faible + Moyenne + Élevée + Manuelle + Modèle déclaré (non vérifié) + Période d'observation (UTC) + Source + Valeur observée + Indices contradictoires ; vérifiez chaque source. + Antérieur / en cache + Estimation heuristique, pas une probabilité. Les valeurs des protocoles sont déclarées par l'appareil. Le fabricant MAC ne prouve pas un modèle ; l'absence de réponse ne prouve pas une absence. + Type automatique + Requêtes désactivées + Analyse rapide + Analyse complète + Analyse approfondie + Importé / périmètre non précisé + Autres candidats diff --git a/Strings/hu-HU.xaml b/Strings/hu-HU.xaml index 99413000..ec3d06b6 100644 --- a/Strings/hu-HU.xaml +++ b/Strings/hu-HU.xaml @@ -522,4 +522,25 @@ A beállítások nem törlődnek. Már létezik ilyen nevű helyszín. Válassz másik nevet. Ez a helyszín nem érhető el vagy nem módosítható. Válassz egy meglévő helyszínt. A helyszín adatai nem olvashatók vagy menthetők. A folytatás előtt zárd be a KillerScan többi példányát, és ellenőrizd a fájljogosultságokat, vagy állíts vissza egy biztonsági másolatot. + Felismerési adatok + Megbízhatóság + Nem érhető el + Alacsony + Közepes + Magas + Kézi + Jelzett modell (nem ellenőrzött) + Megfigyelési időszak (UTC) + Forrás + Megfigyelt érték + Ellentmondó jelek; ellenőrizd az egyes forrásokat. + Korábbi / gyorsítótárazott + Heurisztikus becslés, nem valószínűség. A protokollértékeket maga az eszköz jelenti. A MAC-gyártó nem bizonyít modellt; a hiányzó válasz nem bizonyít távollétet. + Automatikus típus + Kikapcsolt lekérdezések + Gyors vizsgálat + Teljes vizsgálat + Mély vizsgálat + Importált / nem megadott hatókör + További jelöltek diff --git a/Strings/it-IT.xaml b/Strings/it-IT.xaml index 8b4f07b4..8e898bf4 100644 --- a/Strings/it-IT.xaml +++ b/Strings/it-IT.xaml @@ -522,4 +522,25 @@ Le tue impostazioni non verranno rimosse. Esiste già una posizione con questo nome. Scegli un altro nome. Questa posizione non è disponibile o non può essere modificata. Scegli una posizione esistente. Impossibile leggere o salvare i dati della posizione. Chiudi le altre istanze di KillerScan e controlla i permessi dei file o ripristina un backup prima di continuare. + Elementi di identificazione + Affidabilità + Non disponibile + Bassa + Media + Alta + Manuale + Modello dichiarato (non verificato) + Periodo di osservazione (UTC) + Fonte + Valore osservato + Segnali contrastanti; controlla le singole fonti. + Precedente / in cache + Stima euristica, non una probabilità. I valori dei protocolli sono dichiarati dal dispositivo. Il produttore MAC non identifica un modello; la mancanza di risposte non dimostra l'assenza. + Tipo automatico + Richieste disattivate + Scansione rapida + Scansione completa + Scansione approfondita + Importato / ambito non specificato + Altri candidati diff --git a/Strings/ja-JP.xaml b/Strings/ja-JP.xaml index ee7e13ac..a3599476 100644 --- a/Strings/ja-JP.xaml +++ b/Strings/ja-JP.xaml @@ -517,4 +517,25 @@ この名前の拠点はすでに存在します。別の名前を指定してください。 この拠点は利用できないか、変更できません。既存の拠点を選択してください。 拠点データを読み取りまたは保存できませんでした。続行する前に、他の KillerScan を終了し、ファイルのアクセス権を確認するか、バックアップを復元してください。 + 識別の根拠 + 確度 + 情報なし + 低 + 中 + 高 + 手動 + 申告されたモデル(未検証) + 観測期間(UTC) + 情報源 + 観測値 + 矛盾する情報があります。各情報源を確認してください。 + 以前の情報 / キャッシュ + 推定結果であり、確率ではありません。プロトコルの値は機器による自己申告です。MAC のメーカー名はモデルの証明になりません。応答がないことは不在の証明になりません。 + 自動判定の種類 + 無効な照会 + クイックスキャン + フルスキャン + 詳細スキャン + インポート / 範囲不明 + その他の候補 diff --git a/Strings/kk-KZ.xaml b/Strings/kk-KZ.xaml index f7b1de50..a2a2598c 100644 --- a/Strings/kk-KZ.xaml +++ b/Strings/kk-KZ.xaml @@ -548,4 +548,25 @@ Осындай атауы бар орналасу орны бұрыннан бар. Басқа атауды таңдаңыз. Бұл орналасу орны қолжетімсіз немесе оны өзгерту мүмкін емес. Бар орналасу орнын таңдаңыз. Орналасу орнының деректерін оқу немесе сақтау мүмкін болмады. Жалғастырмас бұрын KillerScan бағдарламасының басқа даналарын жауып, файл рұқсаттарын тексеріңіз немесе сақтық көшірмені қалпына келтіріңіз. + Тану негіздері + Сенімділік + Қолжетімсіз + Төмен + Орташа + Жоғары + Қолмен + Хабарланған модель (тексерілмеген) + Бақылау кезеңі (UTC) + Дереккөз + Бақыланған мән + Қайшы белгілер; әр дереккөзді тексеріңіз. + Бұрынғы / кэштен + Ықтималдық емес, эвристикалық баға. Хаттама мәндерін құрылғының өзі хабарлайды. MAC өндірушісі модельді дәлелдемейді; жауаптың болмауы құрылғының жоқтығын дәлелдемейді. + Автоматты түр + Өшірілген сұраулар + Жылдам сканерлеу + Толық сканерлеу + Терең сканерлеу + Импортталған / ауқым көрсетілмеген + Басқа нұсқалар diff --git a/Strings/nb-NO.xaml b/Strings/nb-NO.xaml index 63c75921..9c0eed8f 100644 --- a/Strings/nb-NO.xaml +++ b/Strings/nb-NO.xaml @@ -548,4 +548,25 @@ Innstillingene dine fjernes ikke. Et sted med dette navnet finnes allerede. Velg et annet navn. Dette stedet er utilgjengelig eller kan ikke endres. Velg et eksisterende sted. Stedsdata kunne ikke leses eller lagres. Lukk andre forekomster av KillerScan og kontroller filrettighetene eller gjenopprett en sikkerhetskopi før du fortsetter. + Grunnlag for gjenkjenning + Sikkerhet i vurderingen + Ikke tilgjengelig + Lav + Middels + Høy + Manuell + Rapportert modell (ikke bekreftet) + Observasjonsperiode (UTC) + Kilde + Observert verdi + Motstridende signaler; kontroller de enkelte kildene. + Tidligere / mellomlagret + Heuristisk vurdering, ikke en sannsynlighet. Protokollverdier rapporteres av enheten selv. MAC-produsenten beviser ingen modell; manglende svar beviser ikke fravær. + Automatisk type + Deaktiverte forespørsler + Hurtigskanning + Full skanning + Dybdeskanning + Importert / omfang ikke angitt + Andre kandidater diff --git a/Strings/pl-PL.xaml b/Strings/pl-PL.xaml index 7c09896f..c66f6b9f 100644 --- a/Strings/pl-PL.xaml +++ b/Strings/pl-PL.xaml @@ -491,4 +491,25 @@ Twoje ustawienia nie zostaną usunięte. Lokalizacja o tej nazwie już istnieje. Wybierz inną nazwę. Ta lokalizacja jest niedostępna lub nie można jej zmienić. Wybierz istniejącą lokalizację. Nie można odczytać lub zapisać danych lokalizacji. Przed kontynuowaniem zamknij inne instancje KillerScan i sprawdź uprawnienia do plików lub przywróć kopię zapasową. + Podstawy rozpoznania + Pewność + Niedostępne + Niska + Średnia + Wysoka + Ręcznie + Zgłoszony model (niezweryfikowany) + Okres obserwacji (UTC) + Źródło + Zaobserwowana wartość + Sprzeczne sygnały; sprawdź poszczególne źródła. + Wcześniejsze / z pamięci podręcznej + Ocena heurystyczna, a nie prawdopodobieństwo. Wartości protokołów są zgłaszane przez urządzenie. Producent MAC nie dowodzi modelu; brak odpowiedzi nie dowodzi nieobecności. + Typ automatyczny + Wyłączone zapytania + Szybkie skanowanie + Pełne skanowanie + Dokładne skanowanie + Importowane / zakres nieokreślony + Inne możliwości diff --git a/Strings/pt-BR.xaml b/Strings/pt-BR.xaml index e1ea5ae0..1544ee6e 100644 --- a/Strings/pt-BR.xaml +++ b/Strings/pt-BR.xaml @@ -548,4 +548,25 @@ Suas configurações não serão removidas. Já existe um local com esse nome. Escolha outro nome. Este local não está disponível ou não pode ser alterado. Escolha um local existente. Não foi possível ler ou salvar os dados do local. Feche outras instâncias do KillerScan e verifique as permissões dos arquivos ou restaure um backup antes de continuar. + Evidências de identificação + Confiança + Não disponível + Baixa + Média + Alta + Manual + Modelo informado (não verificado) + Período de observação (UTC) + Fonte + Valor observado + Sinais conflitantes; confira cada fonte. + Anterior / em cache + Estimativa heurística, não uma probabilidade. Os valores dos protocolos são informados pelo próprio dispositivo. O fabricante MAC não identifica um modelo; a falta de respostas não comprova ausência. + Tipo automático + Consultas desativadas + Verificação rápida + Verificação completa + Verificação profunda + Importado / escopo não especificado + Outros candidatos diff --git a/Strings/ru-RU.xaml b/Strings/ru-RU.xaml index 8a05682b..df8202b2 100644 --- a/Strings/ru-RU.xaml +++ b/Strings/ru-RU.xaml @@ -532,4 +532,25 @@ Местоположение с таким именем уже существует. Выберите другое имя. Это местоположение недоступно или не может быть изменено. Выберите существующее местоположение. Не удалось прочитать или сохранить данные местоположения. Перед продолжением закройте другие экземпляры KillerScan и проверьте права доступа к файлам или восстановите резервную копию. + Основания распознавания + Достоверность + Недоступно + Низкая + Средняя + Высокая + Вручную + Заявленная модель (не проверена) + Период наблюдения (UTC) + Источник + Наблюдаемое значение + Противоречивые признаки; проверьте отдельные источники. + Прежнее / из кэша + Эвристическая оценка, а не вероятность. Значения протоколов сообщает само устройство. Производитель MAC не доказывает модель; отсутствие ответов не доказывает отсутствие устройства. + Автоматический тип + Отключённые запросы + Быстрое сканирование + Полное сканирование + Глубокое сканирование + Импортировано / область не указана + Другие варианты diff --git a/Strings/tr-TR.xaml b/Strings/tr-TR.xaml index 171f5510..416216f4 100644 --- a/Strings/tr-TR.xaml +++ b/Strings/tr-TR.xaml @@ -506,4 +506,25 @@ Ayarlarınız silinmeyecek. Bu ada sahip bir konum zaten var. Başka bir ad seçin. Bu konum kullanılamıyor veya değiştirilemiyor. Mevcut bir konum seçin. Konum verileri okunamadı veya kaydedilemedi. Devam etmeden önce diğer KillerScan örneklerini kapatın ve dosya izinlerini kontrol edin veya bir yedeği geri yükleyin. + Tanıma kanıtları + Güven düzeyi + Kullanılamıyor + Düşük + Orta + Yüksek + Elle + Bildirilen model (doğrulanmamış) + Gözlem aralığı (UTC) + Kaynak + Gözlenen değer + Çelişkili sinyaller; kaynakları tek tek kontrol edin. + Önceki / önbellekte + Sezgisel tahmindir, olasılık değildir. Protokol değerlerini cihaz kendisi bildirir. MAC üreticisi modeli kanıtlamaz; yanıt alınmaması yokluğu kanıtlamaz. + Otomatik tür + Devre dışı sorgular + Hızlı tarama + Tam tarama + Derin tarama + İçe aktarılmış / kapsam belirtilmemiş + Diğer adaylar diff --git a/Strings/uk-UA.xaml b/Strings/uk-UA.xaml index 92deccc8..4efd19b3 100644 --- a/Strings/uk-UA.xaml +++ b/Strings/uk-UA.xaml @@ -548,4 +548,25 @@ Розташування з такою назвою вже існує. Виберіть іншу назву. Це розташування недоступне або його неможливо змінити. Виберіть наявне розташування. Не вдалося прочитати або зберегти дані розташування. Перед продовженням закрийте інші екземпляри KillerScan і перевірте дозволи на файли або відновіть резервну копію. + Підстави розпізнавання + Достовірність + Недоступно + Низька + Середня + Висока + Вручну + Заявлена модель (не перевірена) + Період спостереження (UTC) + Джерело + Спостережене значення + Суперечливі ознаки; перевірте окремі джерела. + Попереднє / з кешу + Евристична оцінка, а не ймовірність. Значення протоколів повідомляє сам пристрій. Виробник MAC не доводить модель; відсутність відповідей не доводить відсутність пристрою. + Автоматичний тип + Вимкнені запити + Швидке сканування + Повне сканування + Поглиблене сканування + Імпортовано / область не вказана + Інші варіанти diff --git a/Strings/vi-VN.xaml b/Strings/vi-VN.xaml index db19dc46..1177e604 100644 --- a/Strings/vi-VN.xaml +++ b/Strings/vi-VN.xaml @@ -487,4 +487,25 @@ Cài đặt của bạn sẽ không bị xóa. Đã có địa điểm mang tên này. Hãy chọn tên khác. Địa điểm này không khả dụng hoặc không thể thay đổi. Hãy chọn một địa điểm hiện có. Không thể đọc hoặc lưu dữ liệu địa điểm. Trước khi tiếp tục, hãy đóng các phiên bản KillerScan khác và kiểm tra quyền truy cập tệp hoặc khôi phục bản sao lưu. + Bằng chứng nhận dạng + Độ tin cậy + Không có + Thấp + Trung bình + Cao + Thủ công + Mẫu máy được báo cáo (chưa xác minh) + Khoảng thời gian quan sát (UTC) + Nguồn + Giá trị quan sát + Tín hiệu mâu thuẫn; hãy kiểm tra từng nguồn. + Trước đó / được lưu đệm + Đánh giá theo dấu hiệu, không phải xác suất. Thiết bị tự báo cáo các giá trị giao thức. Nhà sản xuất MAC không chứng minh mẫu máy; không có phản hồi không chứng minh thiết bị vắng mặt. + Loại tự động + Truy vấn đã tắt + Quét nhanh + Quét đầy đủ + Quét sâu + Đã nhập / chưa rõ phạm vi + Các khả năng khác diff --git a/Strings/zh-CN.xaml b/Strings/zh-CN.xaml index b377ec9d..5b12714f 100644 --- a/Strings/zh-CN.xaml +++ b/Strings/zh-CN.xaml @@ -506,4 +506,25 @@ 已存在同名位置。请选择其他名称。 此位置不可用或无法更改。请选择现有位置。 无法读取或保存位置数据。继续前,请关闭其他 KillerScan 实例,检查文件权限或恢复备份。 + 识别依据 + 可信程度 + 无可用信息 + 低 + 中 + 高 + 手动 + 设备报告的型号(未验证) + 观察时段(UTC) + 来源 + 观察到的值 + 信号相互矛盾,请检查各个来源。 + 较早信息 / 缓存 + 这是启发式估计,不是概率。协议值由设备自行报告。MAC 厂商不能证明型号;没有响应不能证明设备不存在。 + 自动识别类型 + 已禁用的查询 + 快速扫描 + 完整扫描 + 深度扫描 + 已导入 / 范围不明 + 其他候选类型 diff --git a/Strings/zh-TW.xaml b/Strings/zh-TW.xaml index 07006d13..46a71360 100644 --- a/Strings/zh-TW.xaml +++ b/Strings/zh-TW.xaml @@ -506,4 +506,25 @@ 已有同名位置。請選擇其他名稱。 此位置無法使用或無法變更。請選擇現有位置。 無法讀取或儲存位置資料。繼續前,請關閉其他 KillerScan 執行個體,檢查檔案權限或還原備份。 + 識別依據 + 可信程度 + 無可用資訊 + 低 + 中 + 高 + 手動 + 裝置回報的型號(未驗證) + 觀察時段(UTC) + 來源 + 觀察到的值 + 訊號互相矛盾,請檢查各個來源。 + 較早資訊 / 快取 + 這是啟發式估計,不是機率。通訊協定值由裝置自行回報。MAC 廠商無法證明型號;沒有回應無法證明裝置不存在。 + 自動識別類型 + 已停用的查詢 + 快速掃描 + 完整掃描 + 深入掃描 + 已匯入 / 範圍不明 + 其他候選類型 diff --git a/docs/device-identification.md b/docs/device-identification.md new file mode 100644 index 00000000..773a6a9d --- /dev/null +++ b/docs/device-identification.md @@ -0,0 +1,125 @@ +# Device recognition, evidence and confidence + +Right-click a device or service and choose **Recognition evidence**, or press +**Ctrl+I** with its row focused. The type cell shows the confidence beside the +existing type. Saved history has the same read-only action. Opening evidence does +not probe a host, open a connection or refresh a saved result. + +The details show the automatic type, contributing source families, alternatives, +raw observed values, observation times and limitations. A user-assigned type is +labelled **Manual** separately from the automatic inference. Clearing it restores +the stored automatic assessment; a user-renamed hostname does not become new +network evidence. Device names, types and saved assessments retain the existing +per-location boundaries. + +## Meaning of confidence + +Confidence applies to the suggested **type/role**, not to ownership, authenticity, +the exact hardware model or device availability. It is a heuristic grade, not a +calibrated probability, and no percentage is displayed. + +- **Not available**: no sufficient evidence, or an older record without evidence. +- **Low**: weak hints such as a name, MAC vendor, ports, or conflicting candidates. +- **Medium**: a specific protocol hint, or the configured gateway/DNS role. +- **High**: at least two non-reused protocol families support the type, with a + specific fingerprint and no close/strong contradictory candidate. +- **Manual**: a user assignment. The automatic assessment is still visible. + +Candidate scoring retains the existing fingerprint catalog, but names contribute +weak hints instead of short-circuiting the protocol evidence. Repeated rules for +one source family contribute at most that family's strongest weight (capped at +15). Candidates need a score of at least 6. Ties use a deterministic type order +and lower confidence; scores are not probabilities. Network-category hints are +combined under Switch/AP, as in the previous generic-network result. + +Missing replies, no open TCP ports or a locally administered MAC no longer imply +IoT, Android or Mobile on their own. An uncertain result can therefore change to +Unknown. OUI/vendor hints may suggest a broad role at low confidence but cannot +establish a model. Original MAC-prefix corrections are retained and the corrected +vendor is recorded when used. + +All protocol strings are self-reported and may be wrong or spoofed. Even a High +type grade does not authenticate the device or its hardware. Related services can +also share an implementation; source-family counting is only a conservative +heuristic, not a claim of statistical independence. + +## Models and time + +A model is shown only for an explicit `model=` / `model:` / `modelname=` declaration +in captured SNMP, HTTP or SSDP values. It remains **reported (unverified)**. This +first version does not infer a model from a hostname, port, OUI or a product-looking +title, and does not use an online product catalog. Distinct model declarations +produce a conflict and no selected model. Declarations longer than 64 characters +are not truncated into a different model. + +Actual scanner results record the per-host observation window in UTC and the +collection time for used multicast data. mDNS/SSDP reused by a deep probe keeps its +earlier time and is labelled cached; it cannot count as fresh corroboration for +High confidence. A hostname's DNS/mDNS/NetBIOS origin is recorded where known. +Network-wide discovery may precede the per-host activity window; its source time +is shown separately. An OUI lookup is labelled cached and has no invented live +observation time. Externally supplied or old data without capture times displays +them as unavailable. Assessment time and observation time are distinct. + +Full, quick and deep modes and explicitly disabled probe choices are saved in the +assessment. Quick mode does not turn its missing port/fingerprint data into negative +evidence. Empty fields mean unavailable information; they do not distinguish a +timeout, a closed port, an unsupported protocol or a skipped operation. This change +does not add probes or claim per-attempt failure diagnostics. + +## Storage, exports and API + +`NetworkDevice.Identification` is optional and omitted for unassessed devices. +Version 1 contains `AutomaticType`, `Confidence`, `ManualType`, `ReportedModel`, +conflict flags, assessment/observation times, scope, disabled choices, observed +evidence, matched rules and ranked candidates. Source IDs and types are stable +language-independent values; UI labels are translated in all 19 supported languages. + +`NetworkScanner.IdentifyDevice(device)` assesses supplied fields without I/O, +global routing hints or saved manual preferences. Existing classification entry +points still return a type and also populate the optional assessment. Normal +scanner entry points attach the scan context before the host applies saved names. + +History takes a separate snapshot of the assessment, including independent rule +and candidate source arrays. Optional fields need no migration; older records +remain readable and are not retroactively assessed. Existing retention still +applies; evidence increases the size of saved scans. No real user history was +migrated during development. + +- CLI JSON includes the nested assessment. PowerShell preserves it as + `.Identification` without adding it to the default table view. The scan/probe + wrappers no longer pass `/quiet` (which suppresses JSON too), and an explicit + pipeline waits for the Windows GUI-subsystem EXE before parsing the output. +- Device CSV adds confidence, reported model and a quoted `Identification JSON` + field after the existing columns; scripts relying on an exact column count must + account for these additions. Service/history CSV also carries the structured data. +- HTML has expandable evidence within each type cell. All remote values are + escaped as text. Observed strings are bounded, control characters are replaced + by spaces, and an HTTP title/server share one source family. + +The topology and terminal table layouts are unchanged. Structured JSON, CSV, +HTML and the details window carry the evidence rather than crowding those views. + +## Isolated validation and demo + +Engine regression checks run on net48 and net10.0 with fabricated observations +and isolated loopback fixtures. They cover weak hints, contradictory sources, +duplicate families, explicit/conflicting models, hostile/long values, manual +assignments, snapshots/JSON, full/quick/deep scope and cached multicast times. + +`SpeedTest.Tests.exe --evidence-ui` exercises all 19 language dictionaries, +focused device/service selection, manual provenance, stored/legacy history and +escaped exports. It is also included in `--release-ui`. Run UI checks in an isolated +data root; retained test runners keep fabricated data separate from installed data. + +`tests/Test-EvidenceModule.ps1 -ExePath ` tests the real module/EXE JSON +round trip using `/demo` and a temporary data root, including usage-error propagation +for an invalid location before any probe can start. The separate scan-options +module tests retain the mocked argument-forwarding and metadata checks. + +`KillerScan.exe --demo` shows fabricated devices. Printer and hypervisor examples +include synthetic protocol values; preset demo types that disagree with the +automatic inference are explicitly marked Manual. Demo capture times remain +unavailable. The CLI `/scan /demo /json` is a safe way to inspect the new JSON shape +without scanning a network. Neither a demo nor loopback fixtures replace a physical +pilot or native-speaker review of machine-assisted translations. diff --git a/tests/KillerScan.Engine.Tests/InterfaceBindingTests.cs b/tests/KillerScan.Engine.Tests/InterfaceBindingTests.cs index db232ece..c51d7cba 100644 --- a/tests/KillerScan.Engine.Tests/InterfaceBindingTests.cs +++ b/tests/KillerScan.Engine.Tests/InterfaceBindingTests.cs @@ -90,7 +90,7 @@ public static Task BoundIcmpAndClassification() var net = Loopback(); net.Gateway = "127.0.0.1"; net.Dns = "127.0.0.1"; var scanner = new NetworkScanner(new ScanBinding(net, () => true)); string type = (string)typeof(NetworkScanner).GetMethod("ClassifyForScan", BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(scanner, new object[] { new NetworkDevice { IpAddress = "127.0.0.1" } })!; + .Invoke(scanner, new object[] { new NetworkDevice { IpAddress = "127.0.0.1" }, new ScanOptions(), "full" })!; Require(type == "Router/DNS", "An explicitly bound scanner uses its own gateway/DNS, not another tab's globals."); } finally { NetworkScanner.GatewayIp = gateway; NetworkScanner.DnsIp = dns; } diff --git a/tests/KillerScan.Engine.Tests/Program.cs b/tests/KillerScan.Engine.Tests/Program.cs index a90d9a99..e065fab2 100644 --- a/tests/KillerScan.Engine.Tests/Program.cs +++ b/tests/KillerScan.Engine.Tests/Program.cs @@ -13,6 +13,11 @@ private static async Task Main() await Run("CIDR expansion excludes the network and broadcast addresses", Subnet); await Run("ARP output parsing keeps IPv4 entries with full MAC addresses", ArpParsing); await Run("Classifier honors manual types, the gateway, DNS and hostname rules", Classifier); + await Run("Recognition keeps weak names, OUI and silent hosts uncertain", RecognitionTests.WeakSignals); + await Run("Recognition requires corroboration and exposes contradictory evidence", RecognitionTests.CorroborationAndConflict); + await Run("Reported models require explicit declarations and bounded evidence", RecognitionTests.ModelsAndUntrustedValues); + await Run("Manual labels, frozen inference, timestamps and JSON remain distinct", RecognitionTests.ManualAndSnapshots); + await Run("Full, quick and deep scans retain provenance and cached timestamps", RecognitionTests.ScanProvenance); await Run("Bundled vendor database loads from the engine assembly", Vendors); await Run("Status text is English, numeric and free of double hyphens", StatusText); await Run("Quick scan reports stages, devices and host hooks through a fake platform", QuickScan); diff --git a/tests/KillerScan.Engine.Tests/RecognitionTests.cs b/tests/KillerScan.Engine.Tests/RecognitionTests.cs new file mode 100644 index 00000000..39a5ccbc --- /dev/null +++ b/tests/KillerScan.Engine.Tests/RecognitionTests.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Text.Json; +using KillerScan.Engine; + +internal static class RecognitionTests +{ + private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } + private static DeviceIdentification Assess(NetworkDevice device, Func? manual = null) + { + device.DeviceType = NetworkScanner.ClassifyDeviceForNetwork(device, "", "", manual); + return device.Identification!; + } + internal static Task WeakSignals() + { + foreach (var device in new[] { new NetworkDevice(), new NetworkDevice { MacAddress = "02:00:00:00:00:01" } }) + { + var result = Assess(device); + Require(result.AutomaticType == "Unknown" && result.Confidence == "None", "Silence/private MAC cannot prove IoT or a mobile device."); + } + var vendor = Assess(new NetworkDevice { Vendor = "Synology" }); + Require(vendor.AutomaticType == "NAS" && vendor.Confidence == "Low" && vendor.ReportedModel == null, + "OUI is a weak role hint, never a model declaration."); + var name = Assess(new NetworkDevice { Hostname = "office-synology" }); + Require(name.AutomaticType == "NAS" && name.Confidence == "Low", "A name alone has low confidence."); + var contrary = Assess(new NetworkDevice { Hostname = "synology", HttpTitle = "VMware ESXi", TlsSubject = "CN=VMware ESXi" }); + Require(contrary.AutomaticType == "Hypervisor" && contrary.Confidence == "High", "Protocol evidence can outweigh a weak name."); + var port = Assess(new NetworkDevice { OpenPorts = [8006] }); + Require(port.AutomaticType == "Hypervisor" && port.Confidence == "Low", "A port alone cannot provide high confidence."); + return Task.CompletedTask; + } + internal static Task CorroborationAndConflict() + { + var one = Assess(new NetworkDevice { HttpTitle = "Proxmox VMware ESXi XenServer", HttpServer = "Proxmox" }); + Require(one.AutomaticType == "Hypervisor" && one.Confidence == "Medium", "Repeated hints from HTTP are one source family."); + var unrelated = Assess(new NetworkDevice { HttpTitle = "VMware ESXi", TlsSubject = "CN=unrelated fixture" }); + Require(unrelated.Confidence == "Medium" && unrelated.Candidates.Single().Sources.SequenceEqual(new[] { "HTTP" }), + "An unrelated TLS value does not corroborate a matched HTTP rule."); + var conflict = Assess(new NetworkDevice { HttpTitle = "VMware ESXi", TlsSubject = "CN=VMware", SnmpDescr = "Printer; model=Unit42" }); + Require(conflict.ConflictingSignals && conflict.Confidence == "Low", "A strong contradictory source is visible even when its score is lower."); + Require(conflict.Rules.Any(rule => rule.Candidate == "Printer" && rule.Sources.Contains("SNMP")), "The alternative has its actual source recorded."); + var tie = Assess(new NetworkDevice { SnmpDescr = "Printer", HttpTitle = "Synology" }); + Require(tie.ConflictingSignals && tie.Confidence == "Low", "Ties cannot silently produce high confidence."); + var independent = Assess(new NetworkDevice { SnmpDescr = "Windows Server", HttpTitle = "Unrelated site" }); + Require(independent.Candidates.Single().Sources.SequenceEqual(new[] { "SNMP" }), "Protocol words in a payload do not create a second source."); + Require(Assess(new NetworkDevice { MdnsServices = { "_PRINTER._TCP.LOCAL" } }).AutomaticType == "Printer", "Service recognition is case-insensitive."); + return Task.CompletedTask; + } + internal static Task ModelsAndUntrustedValues() + { + var named = Assess(new NetworkDevice { Vendor = "Synology", Hostname = "DS999", HttpTitle = "DiskStation DS999" }); + Require(named.ReportedModel == null, "Model-looking names/titles are not explicit model declarations."); + var explicitModel = Assess(new NetworkDevice { SnmpDescr = "Printer; model=Unit42", HttpTitle = "Embedded Web Server; model=Unit42" }); + Require(explicitModel.ReportedModel == "Unit42" && !explicitModel.ModelConflict, "Matching explicit declarations remain labelled as reported, not verified."); + var different = Assess(new NetworkDevice { SnmpDescr = "model=Unit42", SsdpServer = "model=Other99" }); + Require(different.ReportedModel == null && different.ModelConflict, "Conflicting model declarations do not silently pick one."); + var hostile = Assess(new NetworkDevice { HttpTitle = new string('a', 600) + "VMware ESXi", SnmpDescr = "model==HYPERLINK(bad)", SsdpServer = "x\r\n\0", OpenPorts = { 80 } }; + hostile.DeviceType = NetworkScanner.ClassifyDevice(hostile); + string html = ReportExport.BuildHtml(new[] { hostile }, "192.0.2.41", "black"); + Require(html.Contains("<script>bad()</script>") && !html.Contains("VMware ESXi "); + Require(device.Access!.Single().Authentication == "Unknown", "200, data attributes and inactive HTML cannot prove login state."); + HttpAccessObservation.Record(device, 9187, false, 401, "WWW-Authenticate: Basic realm=fixture-secret\r\n", ""); + Require(device.Access!.Single().Authentication == "Required" && device.Access!.Single().Challenge == "Basic" && !JsonSerializer.Serialize(device.Access).Contains("fixture-secret"), "The challenge scheme is evidence; realm/secrets are not retained."); + HttpAccessObservation.Record(device, 9187, false, 403, "", ""); + Require(device.Access!.Single().Authentication == "Unknown", "Forbidden alone does not establish an authentication method."); + HttpAccessObservation.Record(device, 9187, false, 200, "WWW-Authenticate: Basic", ""); + Require(device.Access!.Single().Authentication == "Unknown", "An optional challenge on HTTP 200 does not establish a login requirement."); + HttpAccessObservation.Record(device, 9187, false, 302, "Location: http://other.invalid/?token=not-for-storage", ""); + Require(device.Access!.Single().Redirect && !JsonSerializer.Serialize(device.Access).Contains("not-for-storage"), "Redirects remain evidence without storing target tokens."); + var endpoint = device.Access!.Single(); + Require(DeviceAccess.Address("192.0.2.40;bad", endpoint) == null && DeviceAccess.Address(device.IpAddress, endpoint with { Protocol = "file" }) == null + && DeviceAccess.Address(device.IpAddress, endpoint with { Port = -1 }) == null, "Open addresses accept only numeric hosts, valid ports and allowed schemes."); + Require(DeviceAccess.Address("2001:db8::40", endpoint) == "http://[2001:db8::40]:9187/", "IPv6 URLs retain brackets and port."); + Require(DeviceAccess.ForDevice(new NetworkDevice { IpAddress = "invalid", OpenPorts = [80] }).Count == 0, + "Invalid hosts cannot produce inferred actionable endpoints."); + var restored = JsonSerializer.Deserialize(JsonSerializer.Serialize(device))!; + Require(restored.Access!.Single() == endpoint && DeviceAccess.Web(restored)?.Port == 9187, "JSON and browser selection retain verified nonstandard endpoints."); + Require(!JsonSerializer.Serialize(new NetworkDevice()).Contains("Access"), "Old/unassessed devices retain the optional contract."); + return Task.CompletedTask; + } + + internal static async Task HttpRoundTrip() + { + using var trap = new Fixture(false, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + using var challenge = new Fixture(false, "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Digest realm=fixture\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + using var redirect = new Fixture(false, "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:" + trap.Port + "/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + var device = new NetworkDevice { IpAddress = "127.0.0.1", OpenPorts = [challenge.Port, redirect.Port] }; + await Probe(device); + Require(device.Access?.Count == 2 && device.Access!.Any(item => item.Port == challenge.Port && item.HttpStatus == 401 && item.Authentication == "Required"), "The real probe retains title-less 401 and multiple endpoint results."); + Require(device.Access!.Single(item => item.Port == redirect.Port).Redirect && trap.Requests == 0, "Unbound HTTP never follows a redirect or authenticates automatically."); + Require(challenge.Requests == 1 && redirect.Requests == 1, "A captured HTTP response needs no second protocol attempt or login retry."); + } + + internal static async Task HttpsRoundTrip() + { + const string body = "Secure fixture
"; + using var fixture = new Fixture(true, "HTTP/1.1 200 OK\r\nContent-Length: " + Encoding.UTF8.GetByteCount(body) + "\r\nConnection: close\r\n\r\n" + body); + var device = new NetworkDevice { IpAddress = "127.0.0.1", OpenPorts = [fixture.Port] }; + await Probe(device); + Require(device.Access?.Single() is { Protocol: "https", Authentication: "LoginPage", Verification: "HttpResponse" }, "Deep HTTP detects HTTPS at a nonstandard port with an untrusted fixture certificate. Fixture: " + fixture.LastError + "; access=" + JsonSerializer.Serialize(device.Access)); + Require(DeviceAccess.Address(device.IpAddress, device.Access!.Single()) == "https://127.0.0.1:" + fixture.Port + "/", "The captured scheme and port drive the address."); + } + + internal static async Task StopRequests() + { + using var stalled = new Fixture(false, "", stall: true); + using var next = new Fixture(false, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + using var cancel = new CancellationTokenSource(); + var task = NetworkScanner.ProbeHttpWithCancellationAsync(new NetworkDevice { IpAddress = "127.0.0.1", OpenPorts = [stalled.Port, next.Port] }, IPAddress.Loopback, true, cancel.Token); + var wait = System.Diagnostics.Stopwatch.StartNew(); + while (stalled.Requests == 0 && wait.ElapsedMilliseconds < 3000) await Task.Delay(10); + Require(stalled.Requests == 1, "The controlled stalled HTTP request started."); cancel.Cancel(); + Require(await Task.WhenAny(task, Task.Delay(1500)) == task, "Stop interrupts the active request promptly."); + try { await task; throw new InvalidOperationException("Stop was ignored."); } catch (OperationCanceledException) { } + Require(next.Requests == 0, "Stop prevents all later endpoint attempts."); + } + + private static Task Probe(NetworkDevice device) => (Task)typeof(NetworkScanner) + .GetMethod("ProbeHttpAsync", BindingFlags.NonPublic | BindingFlags.Static)!.Invoke(null, new object[] { device, IPAddress.Loopback, true })!; + + private sealed class Fixture : IDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stop = new(); + private readonly System.Collections.Concurrent.ConcurrentBag _clients = []; + private readonly X509Certificate2? _certificate; + private readonly string _response; + private readonly bool _stall; + internal int Port { get; } + internal int Requests; + internal string? LastError; + internal Fixture(bool tls, string response, bool stall = false) + { + _response = response; + _stall = stall; + if (tls) + { + using var rsa = RSA.Create(); rsa.KeySize = 2048; + var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); +#if NET10_0_OR_GREATER + _certificate = X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null, X509KeyStorageFlags.DefaultKeySet); +#else + _certificate = new X509Certificate2(certificate.Export(X509ContentType.Pfx), (string?)null, X509KeyStorageFlags.DefaultKeySet); +#endif + } + _listener.Start(); Port = ((IPEndPoint)_listener.LocalEndpoint).Port; _ = Accept(); + } + private async Task Accept() + { + try { while (!_stop.IsCancellationRequested) { var client = await _listener.AcceptTcpClientAsync(); _clients.Add(client); _ = Serve(client); } } + catch (Exception error) when (error is SocketException or ObjectDisposedException) { } + } + private async Task Serve(TcpClient client) + { + using (client) + try + { + System.IO.Stream stream = client.GetStream(); + using var ssl = _certificate == null ? null : new SslStream(stream); + if (ssl != null) { await ssl.AuthenticateAsServerAsync(_certificate!, false, SslProtocols.Tls12, false); stream = ssl; } + var buffer = new byte[4096]; int used = 0; + while (used < buffer.Length) + { + int count = await stream.ReadAsync(buffer, used, buffer.Length - used, _stop.Token); + if (count == 0) return; used += count; + if (Encoding.ASCII.GetString(buffer, 0, used).Contains("\r\n\r\n")) break; + } + string request = Encoding.ASCII.GetString(buffer, 0, used); + if (!request.StartsWith("GET / HTTP/", StringComparison.Ordinal)) return; + Require(!request.Contains("Authorization:") && !request.Contains("Cookie:"), "Fixtures receive no credentials/cookies."); + Interlocked.Increment(ref Requests); + if (_stall) { await Task.Delay(Timeout.Infinite, _stop.Token); return; } + byte[] bytes = Encoding.UTF8.GetBytes(_response); await stream.WriteAsync(bytes, 0, bytes.Length, _stop.Token); + } + catch (Exception error) when (error is System.IO.IOException or AuthenticationException or OperationCanceledException or ObjectDisposedException) { LastError = error.ToString(); } + } + public void Dispose() { _stop.Cancel(); _listener.Stop(); foreach (var client in _clients) client.Dispose(); _certificate?.Dispose(); _stop.Dispose(); } + } +} diff --git a/tests/KillerScan.Engine.Tests/InterfaceBindingTests.cs b/tests/KillerScan.Engine.Tests/InterfaceBindingTests.cs index c51d7cba..13bd6dc9 100644 --- a/tests/KillerScan.Engine.Tests/InterfaceBindingTests.cs +++ b/tests/KillerScan.Engine.Tests/InterfaceBindingTests.cs @@ -106,9 +106,9 @@ public static async Task BoundHttp() using var trap = new Listener(IPAddress.Parse("127.0.0.2"), 0); trap.Start(); int trapPort = ((IPEndPoint)trap.LocalEndpoint).Port; - async Task Serve(string response) + async Task Serve(string response, Listener? source = null) { - using var client = await listener.AcceptTcpClientAsync(); + using var client = await (source ?? listener).AcceptTcpClientAsync(); Require(((IPEndPoint)client.Client.RemoteEndPoint!).Address.Equals(IPAddress.Loopback), "HTTP uses the selected source address."); using var reader = new StreamReader(client.GetStream(), Encoding.ASCII, false, 4096, leaveOpen: true); while (await reader.ReadLineAsync() is { Length: > 0 }) { } @@ -120,10 +120,22 @@ async Task Serve(string response) await Probes(run).Http(device, IPAddress.Loopback, true); Require(await Task.WhenAny(serve, Task.Delay(2000)) == serve, "HTTP fixture received the request."); await serve; Require(device.HttpTitle == "Bound title" && device.HttpServer == "Bound-Fixture", "Bound HTTP retains title and server fingerprints."); + Require(device.Access?.Single().Port == port && device.Access!.Single().HttpStatus == 200, + "Bound HTTP stores the actual endpoint response."); serve = Serve("HTTP/1.1 302 Found\r\nServer: Redirect-Fixture\r\nLocation: http://127.0.0.2:" + trapPort + "/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); await Probes(run).Http(new NetworkDevice { OpenPorts = [port] }, IPAddress.Loopback, true); Require(await Task.WhenAny(serve, Task.Delay(2000)) == serve, "Redirect fixture received the request."); await serve; Require(!trap.Pending(), "Bound fingerprinting never follows redirects to another endpoint."); + using var second = new Listener(IPAddress.Loopback, 0); second.Start(); + int secondPort = ((IPEndPoint)second.LocalEndpoint).Port; + var firstReply = Serve("HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=fixture\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + var secondReply = Serve("HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n
", second); + var multiple = new NetworkDevice { IpAddress = "127.0.0.1", OpenPorts = [port, secondPort] }; + await Probes(run).Http(multiple, IPAddress.Loopback, true); + await Task.WhenAll(firstReply, secondReply); + Require(multiple.Access?.Count == 2 && multiple.Access!.Any(item => item.Port == port && item.Authentication == "Required") + && multiple.Access!.Any(item => item.Port == secondPort && item.Authentication == "LoginPage"), + "Bound probes preserve multiple endpoint-specific authentication observations: " + System.Text.Json.JsonSerializer.Serialize(multiple.Access)); } public static async Task BoundDnsRoundTrip() diff --git a/tests/KillerScan.Engine.Tests/Program.cs b/tests/KillerScan.Engine.Tests/Program.cs index 4041534c..62b3de34 100644 --- a/tests/KillerScan.Engine.Tests/Program.cs +++ b/tests/KillerScan.Engine.Tests/Program.cs @@ -15,6 +15,10 @@ private static async Task Main() await Run("ARP output parsing keeps IPv4 entries with full MAC addresses", ArpParsing); await Run("Classifier honors manual types, the gateway, DNS and hostname rules", Classifier); await Run("Recognition keeps weak names, OUI and silent hosts uncertain", RecognitionTests.WeakSignals); + await Run("Access keeps endpoint evidence, login uncertainty and safe addresses", AccessTests.Evidence); + await Run("Real HTTP retains multiple endpoints and refuses redirects/auth retries", AccessTests.HttpRoundTrip); + await Run("Deep access records real HTTPS on a nonstandard port", AccessTests.HttpsRoundTrip); + await Run("Stop cancels HTTP and prevents later access probes", AccessTests.StopRequests); await Run("Recognition requires corroboration and exposes contradictory evidence", RecognitionTests.CorroborationAndConflict); await Run("Reported models require explicit declarations and bounded evidence", RecognitionTests.ModelsAndUntrustedValues); await Run("Manual labels, frozen inference, timestamps and JSON remain distinct", RecognitionTests.ManualAndSnapshots); @@ -100,7 +104,7 @@ private static Task Subnet() try { NetworkScanner.GetAddressesInSubnet(cidr); } catch (ArgumentOutOfRangeException ex) { - rejected = ex.ParamName == "cidr" && ex.Message.Contains("65,536", StringComparison.Ordinal); + rejected = ex.ParamName == "cidr" && ex.Message.Contains(ScanTargets.MaxAddresses.ToString("N0"), StringComparison.Ordinal); } Require(rejected, cidr + " should be rejected before its hosts are allocated"); } diff --git a/tests/KillerScan.Engine.Tests/RecognitionTests.cs b/tests/KillerScan.Engine.Tests/RecognitionTests.cs index 39a5ccbc..18b7f98d 100644 --- a/tests/KillerScan.Engine.Tests/RecognitionTests.cs +++ b/tests/KillerScan.Engine.Tests/RecognitionTests.cs @@ -92,7 +92,7 @@ internal override void FlushDns() { } internal override Task> Mdns(CancellationToken token) => Task.FromResult(new Dictionary { ["192.0.2.10"] = new() { Services = { "_printer._tcp.local" } } }); internal override Task> Ssdp(CancellationToken token) => Task.FromResult(new Dictionary { ["192.0.2.10"] = "model=Unit42" }); - internal override Task Http(NetworkDevice device, IPAddress address, bool deep) { device.HttpTitle = "Embedded Web Server; model=Unit42"; return Task.CompletedTask; } + internal override Task Http(NetworkDevice device, IPAddress address, bool deep, CancellationToken token = default) { device.HttpTitle = "Embedded Web Server; model=Unit42"; return Task.CompletedTask; } internal override Task Tls(NetworkDevice device, IPAddress address, bool deep) => Task.CompletedTask; internal override Task Ssh(NetworkDevice device, IPAddress address) => Task.CompletedTask; internal override Task Snmp(NetworkDevice device, IPAddress address) { device.SnmpDescr = "Printer; model=Unit42"; return Task.CompletedTask; } diff --git a/tests/KillerScan.Engine.Tests/ScanOptionsTests.cs b/tests/KillerScan.Engine.Tests/ScanOptionsTests.cs index 02e95063..a0685270 100644 --- a/tests/KillerScan.Engine.Tests/ScanOptionsTests.cs +++ b/tests/KillerScan.Engine.Tests/ScanOptionsTests.cs @@ -10,6 +10,7 @@ private sealed class Probes : NetworkScanner.ScanProbes { internal readonly ConcurrentDictionary Calls = new(); internal bool FailMulticast; + internal HashSet ResponsivePorts = [80, 443, 22]; private void Hit(string name) => Calls.AddOrUpdate(name, 1, (_, count) => count + 1); internal override IReadOnlyDictionary ReadNeighbors() { Hit("neighbor-cache"); return new Dictionary { [Host] = "" }; } @@ -20,7 +21,7 @@ internal override IReadOnlyDictionary ReadNeighbors() internal override Task Ping(IPAddress address, int timeout) { Hit("icmp"); return Task.FromResult(null); } internal override Task TcpAlive(IPAddress address) { Hit("tcp-liveness"); return Task.FromResult(true); } internal override Task Connect(IPAddress address, int port, int timeout) - { Hit("ports"); return Task.FromResult(port is 80 or 443 or 22); } + { Hit("ports"); return Task.FromResult(ResponsivePorts.Contains(port)); } internal override Task> Mdns(CancellationToken ct) { Hit("mdns"); @@ -30,7 +31,7 @@ internal override Task Connect(IPAddress address, int port, int timeout) } internal override Task> Ssdp(CancellationToken ct) { Hit("ssdp"); return Task.FromResult(new Dictionary { [Host] = "Fixture" }); } - internal override Task Http(NetworkDevice device, IPAddress address, bool deep) { Hit("http"); return Task.CompletedTask; } + internal override Task Http(NetworkDevice device, IPAddress address, bool deep, CancellationToken token = default) { Hit("http"); return Task.CompletedTask; } internal override Task Tls(NetworkDevice device, IPAddress address, bool deep) { Hit("tls"); return Task.CompletedTask; } internal override Task Ssh(NetworkDevice device, IPAddress address) { Hit("ssh"); return Task.CompletedTask; } internal override Task Snmp(NetworkDevice device, IPAddress address) { Hit("snmp"); return Task.CompletedTask; } @@ -77,6 +78,16 @@ public static async Task CompatibilityAndIsolation() Require(defaults.Calls.ContainsKey(feature), "The existing API still enables " + feature); Require(defaults.Calls.ContainsKey("dns-flush"), "The existing API keeps the DNS cache flush."); + foreach (int port in new[] { 5357, 32400 }) + { + var onlyWeb = new Probes { ResponsivePorts = [port] }; + await new NetworkScanner(onlyWeb).ScanSubnetAsync(Host, CancellationToken.None); + Require(onlyWeb.Calls.ContainsKey("http"), "A service alone on port " + port + " starts the normal HTTP fingerprint."); + var disabled = new Probes { ResponsivePorts = [port] }; + await new NetworkScanner(disabled).ScanSubnetAsync(Host, CancellationToken.None, new ScanOptions().Without("http")); + Require(!disabled.Calls.ContainsKey("http"), "The additional web port still respects HTTP off."); + } + var probes = new Probes(); var scanner = new NetworkScanner(probes); var first = await scanner.ScanSubnetAsync(Host, CancellationToken.None); diff --git a/tests/SpeedTest.Tests/AccessUiChecks.cs b/tests/SpeedTest.Tests/AccessUiChecks.cs new file mode 100644 index 00000000..a4d18971 --- /dev/null +++ b/tests/SpeedTest.Tests/AccessUiChecks.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Windows; +using System.Windows.Controls; +using KillerScan.Controls; +using KillerScan.Engine; +using KillerScan.Services; +using KillerScan.Shell; +using AccessText = KillerScan.Services.AccessText; + +internal static class AccessUiChecks +{ + private const BindingFlags Instance = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } + private static System.Collections.Generic.IEnumerable Children(DependencyObject parent) where T : DependencyObject + { + foreach (var child in LogicalTreeHelper.GetChildren(parent).OfType()) + { + if (child is T found) yield return found; + foreach (var nested in Children(child)) yield return nested; + } + } + internal static void Run(MainWindow window, Action capture) + { + Require(DemoData.Enabled, "Access fixtures require isolated demo history."); + var endpoint = new AccessEndpoint { Protocol = "https", Port = 9187, Verification = "HttpResponse", Authentication = "Required", Challenge = "Basic", HttpStatus = 401, + ObservedAtUtc = new DateTimeOffset(2026, 10, 20, 8, 0, 0, TimeSpan.Zero) }; + var device = new NetworkDevice { IpAddress = "192.0.2.40", Hostname = "access-fixture", OpenPorts = { 9187, 53 }, Access = new() { endpoint } }; + var old = ScanHistory.Entries.ToArray(); + var stored = ScanHistory.Record(device.IpAddress, new[] { device }).Current.Devices.Single(); + device.Access!.Clear(); + Require(stored.Access!.Single() == endpoint, "History owns its access list snapshot."); + device.Access.Add(endpoint); ScanHistory.SeedDemo(old); + string csv = ReportExport.BuildCsv(new[] { device }); + string html = ReportExport.BuildHtml(new[] { device }, device.IpAddress, "black"); + Require(csv.Contains("Access JSON") && csv.Contains("9187") && html.Contains("https://192.0.2.40:9187/"), "Exports retain the captured endpoint."); + var workspace = (ScanWorkspace)typeof(MainWindow).GetField("_scanWorkspace", Instance)!.GetValue(window)!; + var actionField = workspace.GetType().GetField("DeviceAction", Instance)!; + object? original = actionField.GetValue(workspace); actionField.SetValue(workspace, null); + ScanDeviceActionEventArgs? raised = null; + workspace.DeviceAction += (_, e) => raised = e; + try + { + workspace.OpenAccess(device, endpoint); + Require(raised?.Action == "Browser" && raised.ServicePort == 9187 && ReferenceEquals(raised.Access, endpoint), "The existing open action receives the selected exact endpoint."); + raised = null; workspace.OpenAccess(device, endpoint with { Protocol = "file" }); + Require(raised == null, "Unsupported schemes cannot launch a process."); + workspace.Session.Devices.Add(device); workspace.SetView("services"); + var services = (DataGrid)workspace.FindName("ServicesGrid"); + var row = services.Items.Cast().Single(item => (string)item.GetType().GetProperty("IpAddress")!.GetValue(item)! == device.IpAddress && (int)item.GetType().GetProperty("Port")!.GetValue(item)! == 9187); + Require((string)row.GetType().GetProperty("BrowserAddress")!.GetValue(row)! == "https://192.0.2.40:9187/", "Services use the observed protocol on a nonstandard port."); + services.SelectedItem = row; services.CurrentItem = row; + workspace.GetType().GetMethod("ServicesGrid_ContextMenuOpening", Instance)!.Invoke(workspace, new object?[] { services, null }); + var browser = services.ContextMenu.Items.OfType().Single(item => item.Tag as string == "Browser"); + Require(browser.Visibility == Visibility.Visible, "The existing browser action is available for a verified nonstandard endpoint."); + workspace.Session.Devices.Remove(device); workspace.SetView("devices"); + } + finally { actionField.SetValue(workspace, original); } + foreach (Locale locale in Enum.GetValues(typeof(Locale))) + { + typeof(MainWindow).GetMethod("Lang_Checked", Instance)!.Invoke(window, new object[] { new RadioButton { Tag = locale.ToString() }, new RoutedEventArgs() }); + var resources = Application.Current.Resources.MergedDictionaries[locale == Locale.EnUS ? 1 : 2]; + foreach (string key in new[] { "Title", "None", "Authentication", "Unknown", "Required", "LoginPage", "HttpResponse", "SshBanner", "TcpOpen", "PortHint", "Redirect", "Note", "NoTime" }) + Require(resources.Contains("Str_Access_" + key), locale + ": own access resource " + key); + int opened = 0; string? copied = null; + var dialog = new DeviceAccessWindow(device, chosen => { Require(chosen == endpoint, "Open uses the selected endpoint."); opened++; }, value => copied = value); + Require(opened == 0 && copied == null, "Viewing access does not connect or copy automatically."); + var buttons = Children