diff --git a/Apps/TyposquattingDetector/App.cs b/Apps/TyposquattingDetector/App.cs new file mode 100644 index 000000000..e79204ce7 --- /dev/null +++ b/Apps/TyposquattingDetector/App.cs @@ -0,0 +1,511 @@ +/* +Technitium DNS Server +Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com) +Copyright (C) 2025 Zafer Balkan (zafer@zaferbalkan.com) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using DnsServerCore.ApplicationCommon; +using System; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.EDnsOptions; +using TechnitiumLibrary.Net.Dns.ResourceRecords; +using TechnitiumLibrary.Net.Http.Client; + +namespace TyposquattingDetector +{ + public sealed partial class App : IDnsApplication, IDnsRequestBlockingHandler + { + #region variables + + private const string DefaultDomainListUrl = "https://downloads.majestic.com/majestic_million.csv"; + private CancellationTokenSource? _appShutdownCts; + private Config? _config; + private volatile TyposquattingDetector? _detector; + private IDnsServer? _dnsServer; + private string? _domainListFilePath; + private DnsSOARecordData? _soaRecord; + private TimeSpan _updateInterval; + private Task? _updateLoopTask; + private static readonly JsonSerializerOptions _options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + private bool _changed = false; + #endregion variables + + #region IDisposable + + public void Dispose() + { + _appShutdownCts?.Cancel(); + try + { + if (_updateLoopTask != null) + { + _ = Task.WhenAny(_updateLoopTask, Task.Delay(TimeSpan.FromSeconds(2))).GetAwaiter().GetResult(); + } + } + catch + { + } + finally + { + _appShutdownCts?.Dispose(); + } + } + + #endregion IDisposable + + #region public + + public async Task InitializeAsync(IDnsServer dnsServer, string config) + { + _dnsServer = dnsServer; + try + { + _soaRecord = new DnsSOARecordData(_dnsServer.ServerDomain, _dnsServer.ResponsiblePerson.Address, 1, 14400, 3600, 604800, 60); + + try + { + _config = JsonSerializer.Deserialize(config, _options); + } + catch (Exception e) + { + throw new AggregateException("Invalid configuration for TyposquattingDetector app.", e); + } + + Validator.ValidateObject(_config!, new ValidationContext(_config!), validateAllProperties: true); + _updateInterval = ParseUpdateInterval(_config!.UpdateInterval); + _appShutdownCts = new CancellationTokenSource(); + + string configDir = _dnsServer.ApplicationFolder; + Directory.CreateDirectory(configDir); + _domainListFilePath = Path.Combine(configDir, "majestic_million.csv"); + + if (!Path.Exists(_domainListFilePath)) + { + _dnsServer.WriteLog($"Typosquatting Detector: Started downloading domain list to path: '{_domainListFilePath}'."); + + try + { + Uri domainList = new Uri(DefaultDomainListUrl); + using HttpClient httpClient = CreateHttpClient(domainList, _config.DisableTlsValidation); + using Stream stream = await httpClient.GetStreamAsync(domainList); + using FileStream fs = new FileStream(_domainListFilePath, FileMode.Create, FileAccess.Write, FileShare.None); + await stream.CopyToAsync(fs, _appShutdownCts.Token); + + _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list from '{domainList}' to '{_domainListFilePath}'."); + } + catch (Exception ex) + { + _dnsServer.WriteLog($"FATAL: Failed to download domain list. Error: {ex.Message}"); + _dnsServer.WriteLog(ex); + } + } + else + { + _dnsServer.WriteLog($"Typosquatting Detector: Domain list exists at path: '{_domainListFilePath}'."); + } + + + // Re-read file to calculate hash (or use a CryptoStream during download) + using (FileStream fs = new FileStream(_domainListFilePath, FileMode.Open, FileAccess.Read)) + { + string sha256 = Convert.ToHexString(await SHA256.HashDataAsync(fs)); + _dnsServer.WriteLog($"Typosquatting Detector: SHA256 hash of downloaded domain list: {sha256}"); + + string hashPath = Path.Combine(configDir, "majestic_million.csv.sha256"); + string? previousHash = null; + if (File.Exists(hashPath)) + { + // Safely read the first line; handle empty or corrupted hash file + previousHash = File.ReadLines(hashPath).FirstOrDefault()?.Trim(); + } + if (!string.IsNullOrEmpty(previousHash) && string.Equals(previousHash, sha256, StringComparison.OrdinalIgnoreCase)) + { + _changed = false; + _dnsServer.WriteLog($"Typosquatting Detector: Downloaded domain list is identical to the previous one. No changes made."); + } + else + { + await File.WriteAllTextAsync(hashPath, sha256, _appShutdownCts.Token); + _changed = true; + _dnsServer.WriteLog($"Typosquatting Detector: Hash file is saved."); + } + } + + + // We await this so InitializeAsync doesn't finish until the detector is ready. + await UpdateDomainListAsync(_appShutdownCts.Token); + + // Now that _detector is initiated, start the periodic update loop + _updateLoopTask = StartUpdateLoopAsync(_appShutdownCts.Token); + + _ = _updateLoopTask.ContinueWith(t => + { + if (t.IsFaulted) + { + _dnsServer.WriteLog($"FATAL: Update loop terminated unexpectedly: {t.Exception?.GetBaseException().Message}"); + _dnsServer.WriteLog(t.Exception); + } + }, TaskContinuationOptions.OnlyOnFaulted); + } + catch (Exception ex) + { + _dnsServer.WriteLog($"FATAL: Typosquatting Detector failed to initialize. Check configuration. Error: {ex.Message}"); + _dnsServer.WriteLog(ex); + } + } + + public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) + { + return Task.FromResult(false); + } + + public Task ProcessRequestAsync(DnsDatagram request, IPEndPoint remoteEP) + { + if (_config?.Enable != true) + { + return Task.FromResult(null); + } + + // Download takes time. Let's not break the app. + if (_detector is null) + { + return Task.FromResult(null); + } + + DnsQuestionRecord question = request.Question[0]; + Result res = _detector.Check(question.Name); + if (res.IsSuspicious == false) + { + return Task.FromResult(null); + } + + string blockingReport = $"source=typosquatting-detector;domain={res.Query};severity={res.Severity};reason={res.Reason}"; + + EDnsOption[]? options = null; + if (_config.AddExtendedDnsError && request.EDNS is not null) + { + options = new EDnsOption[] { new EDnsOption(EDnsOptionCode.EXTENDED_DNS_ERROR, new EDnsExtendedDnsErrorOptionData(EDnsExtendedDnsErrorCode.Blocked, blockingReport)) }; + } + + if (_config.AllowTxtBlockingReport && question.Type == DnsResourceRecordType.TXT) + { + DnsResourceRecord[] answer = new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.TXT, question.Class, 60, new DnsTXTRecordData(blockingReport)) }; + return Task.FromResult(new DnsDatagram( + ID: request.Identifier, + isResponse: true, + OPCODE: DnsOpcode.StandardQuery, + authoritativeAnswer: false, + truncation: false, + recursionDesired: request.RecursionDesired, + recursionAvailable: true, + authenticData: false, + checkingDisabled: false, + RCODE: DnsResponseCode.NoError, + question: request.Question, + answer: answer, + authority: null, + additional: null, + udpPayloadSize: request.EDNS is null ? ushort.MinValue : _dnsServer!.UdpPayloadSize, + ednsFlags: EDnsHeaderFlags.None, + options: options + )); + } + + DnsResourceRecord[] authority = { new DnsResourceRecord(question.Name, DnsResourceRecordType.SOA, question.Class, 60, _soaRecord) }; + return Task.FromResult(new DnsDatagram( + ID: request.Identifier, + isResponse: true, + OPCODE: DnsOpcode.StandardQuery, + authoritativeAnswer: true, + truncation: false, + recursionDesired: request.RecursionDesired, + recursionAvailable: true, + authenticData: false, + checkingDisabled: false, + RCODE: DnsResponseCode.NxDomain, + question: request.Question, + answer: null, + authority: authority, + additional: null, + udpPayloadSize: request.EDNS is null ? ushort.MinValue : _dnsServer!.UdpPayloadSize, + ednsFlags: EDnsHeaderFlags.None, + options: options + )); + } + + #endregion public + + #region private + + private static TimeSpan ParseUpdateInterval(string interval) + { + if (string.IsNullOrWhiteSpace(interval) || interval.Length < 2) + { + throw new FormatException("Update interval is not in a valid format (e.g., '30m', '12h', '1d', '2w')."); + } + + string unit = interval.Substring(interval.Length - 1).ToLowerInvariant(); + string valueString = interval.Substring(0, interval.Length - 1); + + if (!int.TryParse(valueString, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) || value <= 0) + { + throw new FormatException($"Invalid numeric value '{valueString}' in update interval."); + } + + switch (unit) + { + case "m": + return TimeSpan.FromMinutes(value); + + case "h": + return TimeSpan.FromHours(value); + + case "d": + return TimeSpan.FromDays(value); + + case "w": + return TimeSpan.FromDays(value * 7); + + default: + throw new FormatException($"Invalid unit '{unit}' in update interval. Allowed units are 'm', 'h', 'd', 'w'."); + } + } + + private HttpClient CreateHttpClient(Uri serverUrl, bool disableTlsValidation) + { + HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + handler.Proxy = _dnsServer!.Proxy; + handler.NetworkType = _dnsServer.PreferIPv6 ? HttpClientNetworkType.PreferIPv6 : HttpClientNetworkType.Default; + handler.DnsClient = _dnsServer; + + if (disableTlsValidation) + { + handler.InnerHandler.SslOptions.RemoteCertificateValidationCallback = delegate (object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) + { + return true; + }; + + _dnsServer.WriteLog($"WARNING: TLS certificate validation is DISABLED for server: {serverUrl}"); + } + + return new HttpClient(handler); + } + + private async Task StartUpdateLoopAsync(CancellationToken cancellationToken) + { + using PeriodicTimer timer = new PeriodicTimer(_updateInterval); + + // If init already checked hash and found no change, you can skip the *first* interval check. + bool skipFirst = !Volatile.Read(ref _changed); + + // Jitter to avoid stampede after restart + await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(0, 60)), cancellationToken); + + while (!cancellationToken.IsCancellationRequested) + { + if (skipFirst) + { + skipFirst = false; + } + else + { + bool flowControl = await TryUpdate(cancellationToken); + if (!flowControl) + break; + } + + await timer.WaitForNextTickAsync(cancellationToken); + } + } + + private async Task TryUpdate(CancellationToken cancellationToken) + { + try + { + bool changed = await DownloadIfChangedAndReloadAsync(cancellationToken); + if (changed) + _dnsServer!.WriteLog("Typosquatting Detector: Domain list updated and detector reloaded."); + } + catch (OperationCanceledException) + { + _dnsServer!.WriteLog("Typosquatting Detector: Update loop is shutting down gracefully."); + return false; + } + catch (Exception ex) + { + _dnsServer!.WriteLog($"ERROR: Typosquatting Detector update failed. {ex.Message}"); + _dnsServer!.WriteLog(ex); + } + + return true; + } + + private async Task DownloadIfChangedAndReloadAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) return false; + + string configDir = _dnsServer!.ApplicationFolder; + string majesticPath = Path.GetFullPath(_domainListFilePath!); + + if (!majesticPath.StartsWith(configDir, StringComparison.OrdinalIgnoreCase)) + throw new SecurityException("Access Denied"); + + string hashPath = Path.Combine(configDir, "majestic_million.csv.sha256"); + string tempPath = Path.Combine(configDir, "majestic_million.csv.tmp"); + + // Avoid concurrent temp collisions (paranoia) + if (File.Exists(tempPath)) + { + try { File.Delete(tempPath); } catch { /* ignore */ } + } + + _dnsServer.WriteLog("Typosquatting Detector: Checking for updated domain list..."); + + // Download to temp and compute hash while writing (single pass) + string newHash; + Uri domainList = new Uri(DefaultDomainListUrl); + + using (HttpClient httpClient = CreateHttpClient(domainList, _config!.DisableTlsValidation)) + using (Stream netStream = await httpClient.GetStreamAsync(domainList, cancellationToken)) + using (FileStream fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 128 * 1024, useAsync: true)) + using (SHA256 sha = SHA256.Create()) + using (CryptoStream crypto = new CryptoStream(fs, sha, CryptoStreamMode.Write, leaveOpen: true)) + { + await netStream.CopyToAsync(crypto, 128 * 1024, cancellationToken); + await crypto.FlushAsync(cancellationToken); + crypto.FlushFinalBlock(); + + newHash = Convert.ToHexString(sha.Hash!); + } + + // Read old hash (if any) + string? oldHash = null; + if (File.Exists(hashPath)) + oldHash = File.ReadLines(hashPath).FirstOrDefault()?.Trim(); + + if (!string.IsNullOrEmpty(oldHash) && string.Equals(oldHash, newHash, StringComparison.OrdinalIgnoreCase)) + { + // No change → delete temp + try { File.Delete(tempPath); } catch { /* ignore */ } + Volatile.Write(ref _changed, false); + _dnsServer.WriteLog("Typosquatting Detector: No change in domain list."); + return false; + } + + // Changed → replace live file atomically (temp is in same directory) + // File.Move(tempPath, majesticPath, overwrite: true) is supported on modern .NET. + File.Move(tempPath, majesticPath, overwrite: true); + + await File.WriteAllTextAsync(hashPath, newHash, cancellationToken); + Volatile.Write(ref _changed, true); + + // Reload detector from the updated file + await UpdateDomainListAsync(cancellationToken); + + return true; + } + + private Task UpdateDomainListAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) return Task.CompletedTask; + + try + { + _dnsServer!.WriteLog("Typosquatting Detector: Processing domain list..."); + + string configDirFullPath = Path.GetFullPath(_dnsServer.ApplicationFolder); + + string majesticPath = Path.GetFullPath(_domainListFilePath!); + EnsureUnderBaseSymlinkSafe(configDirFullPath, majesticPath); + + string customListPath = string.Empty; + if (!string.IsNullOrWhiteSpace(_config!.Path)) + { + customListPath = Path.GetFullPath(_config.Path); + EnsureUnderBaseSymlinkSafe(configDirFullPath, customListPath); + } + + TyposquattingDetector newDetector = new TyposquattingDetector(majesticPath, customListPath, _config.FuzzyMatchThreshold); + TyposquattingDetector? oldDetector = Interlocked.Exchange(ref _detector, newDetector); + oldDetector?.Dispose(); + + _dnsServer.WriteLog("Typosquatting Detector: Processing completed."); + } + catch (IOException ex) + { + _dnsServer!.WriteLog($"ERROR: Failed to read cache file '{_domainListFilePath}'. Error: {ex.Message}"); + } + + return Task.CompletedTask; + } + + private static void EnsureUnderBaseSymlinkSafe(string baseDirFullPath, string candidateFullPath) + { + baseDirFullPath = Path.GetFullPath(baseDirFullPath); + candidateFullPath = Path.GetFullPath(candidateFullPath); + + // First: lexical traversal guard + string rel = Path.GetRelativePath(baseDirFullPath, candidateFullPath); + if (rel == ".." || + rel.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || + rel.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)) + throw new SecurityException("Access Denied"); + + // Second: resolve each component and block symlink escape + var current = new DirectoryInfo(candidateFullPath); + while (current != null && + !current.FullName.Equals(baseDirFullPath, StringComparison.OrdinalIgnoreCase)) + { + // If any component is a symlink → reject + if ((current.Attributes & FileAttributes.ReparsePoint) != 0) + throw new SecurityException("Access Denied"); + + current = current.Parent; + } + + // If we walked to filesystem root without hitting base folder → reject + if (current == null) + throw new SecurityException("Access Denied"); + } + + #endregion private + + #region properties + + public string Description + { + get + { + return "Evaluates queried domains against a trusted corpus and flags visually similar near-matches as potential typosquatting. Allows blocking of suspicious queries and exposes structured detection details. The fuzzy-match threshold and optional custom domain list are operator-tunable; adjust cautiously to reduce false-positive impact."; + } + } + + #endregion properties + } +} \ No newline at end of file diff --git a/Apps/TyposquattingDetector/Config.cs b/Apps/TyposquattingDetector/Config.cs new file mode 100644 index 000000000..58c1b4e8f --- /dev/null +++ b/Apps/TyposquattingDetector/Config.cs @@ -0,0 +1,106 @@ +/* +Technitium DNS Server +Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com) +Copyright (C) 2025 Zafer Balkan (zafer@zaferbalkan.com) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace TyposquattingDetector +{ + public sealed partial class App + { + private class Config + { + [JsonPropertyName("addExtendedDnsError")] + public bool AddExtendedDnsError { get; set; } = true; + + [JsonPropertyName("allowTxtBlockingReport")] + public bool AllowTxtBlockingReport { get; set; } = true; + + [JsonPropertyName("disableTlsValidation")] + public bool DisableTlsValidation { get; set; } = false; + + [JsonPropertyName("enable")] + public bool Enable { get; set; } = true; + + [JsonPropertyName("fuzzyMatchThreshold")] + [Range(75, 90, ErrorMessage = "fuzzyMatchThreshold must be between 75 and 90.")] + [Required(ErrorMessage = "fuzzyMatchThreshold is a required configuration property. The lower threshold means more false positives.")] + public int FuzzyMatchThreshold { get; set; } = 75; + + [JsonPropertyName("customList")] + [CustomValidation(typeof(FileContentValidator), nameof(FileContentValidator.ValidateDomainFile))] + public string? Path { get; set; } + + [JsonPropertyName("updateInterval")] + [Required(ErrorMessage = "updateInterval is a required configuration property.")] + [RegularExpression(@"^\d+[mhdw]$", ErrorMessage = "Invalid interval format. Use a number followed by 'm', 'h', or 'd' (e.g., '90m', '2h', '7d').", MatchTimeoutInMilliseconds = 3000)] + public string UpdateInterval { get; set; } = "30d"; + } + + public partial class FileContentValidator + { + // Optimized Regex: Compiled for performance during "Happy Path" scans + private static readonly Regex DomainRegex = DomainPattern(); + + public static ValidationResult? ValidateDomainFile(string? path, ValidationContext context) + { + // 1. If path is null/empty, we assume validation is not required here + // (Use [Required] on the property if you want to force a path to be provided) + if (string.IsNullOrWhiteSpace(path)) return ValidationResult.Success; + + // 2. Existence Check + if (!File.Exists(path)) + return new ValidationResult($"File not found: {path}"); + + try + { + // 3. Stream through lines + // If the file is empty, this loop is simply skipped + foreach (string line in File.ReadLines(path)) + { + string trimmedLine = line.Trim(); + + // Skip truly empty lines (whitespace only) + if (string.IsNullOrEmpty(trimmedLine)) continue; + + // 4. Fail-Fast Logic + // If any content exists, it MUST follow the domain rules + if (trimmedLine.Contains('*') || !DomainRegex.IsMatch(trimmedLine)) + { + return new ValidationResult($"Invalid content: '{trimmedLine}'. Wildcards are not allowed."); + } + } + } + catch (IOException ex) + { + return new ValidationResult($"File access error: {ex.Message}"); + } + + // 5. Success Path + // Reached if the file was empty OR all lines passed validation + return ValidationResult.Success; + } + + [GeneratedRegex(@"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex DomainPattern(); + } + } +} \ No newline at end of file diff --git a/Apps/TyposquattingDetector/DomainCache.cs b/Apps/TyposquattingDetector/DomainCache.cs new file mode 100644 index 000000000..a7b9d6c02 --- /dev/null +++ b/Apps/TyposquattingDetector/DomainCache.cs @@ -0,0 +1,281 @@ +/* +Technitium DNS Server +Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com) +Copyright (C) 2025 Zafer Balkan (zafer@zaferbalkan.com) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using Nager.PublicSuffix; +using Nager.PublicSuffix.RuleProviders; +using Nager.PublicSuffix.RuleProviders.CacheProviders; +using System; +using System.Collections.Concurrent; +using System.Net.Http; +using System.Threading; + +namespace TyposquattingDetector +{ + + + /// + /// Thread-safe cache for parsed domain information using the SIEVE eviction algorithm. + /// SIEVE provides better scan resistance than LRU, making it ideal for DNS workloads + /// where one-time queries (typos, DGA domains) are common. + /// + /// Reference: "SIEVE is Simpler than LRU: an Efficient Turn-Key Eviction Algorithm for + /// Web Caches" (NSDI '24) + /// + internal sealed class DomainCache + { + #region variables + + private const int MaxSize = 10000; + private const int StringPoolMaxSize = 10000; + private static readonly DomainInfo Empty = new DomainInfo(); + + // ADR: Loading the PSL must not block or fail plugin startup. We defer + // initialization and make it best-effort to avoid network dependencies. + private static readonly Lazy _parser = new Lazy(InitializeParser); + + private static readonly HttpClient _pslHttpClient = new HttpClient(); + + private static readonly Lazy _sharedRuleProvider = + new Lazy(static () => + { + LocalFileSystemCacheProvider cacheProvider = new LocalFileSystemCacheProvider(); + CachedHttpRuleProvider rp = new CachedHttpRuleProvider(cacheProvider, _pslHttpClient); + rp.BuildAsync().GetAwaiter().GetResult(); + return rp; + }, isThreadSafe: true); + + private readonly ConcurrentDictionary _cache = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _stringPool = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly Lock _evictionLock = new Lock(); + + // SIEVE data structures + private CacheNode? _head; + private CacheNode? _tail; + private CacheNode? _hand; + #endregion + + #region public + public DomainInfo GetOrAdd(string domainName) + { + if (string.IsNullOrWhiteSpace(domainName)) + return Empty; + + // Fast path: try cache lookup with original name first (case-insensitive) + if (_cache.TryGetValue(domainName, out CacheNode? node)) + { + node.Visited = true; + return node.Domain; + } + + // NormalizeConfig only if needed, using string pool to reduce allocations + string normalizedName = GetPooledNormalizedName(domainName); + + // Check cache again with normalized name (may differ from original) + if (!ReferenceEquals(normalizedName, domainName) && + _cache.TryGetValue(normalizedName, out node)) + { + node.Visited = true; + return node.Domain; + } + + DomainInfo domain = Parse(domainName); + AddToCache(normalizedName, domain); + return domain; + } + + + public void Clear() + { + lock (_evictionLock) + { + _cache.Clear(); + _stringPool.Clear(); + _head = null; + _tail = null; + _hand = null; + } + } + + #endregion + + #region private + + /// + /// Returns a pooled, normalized version of the domain name to reduce allocations. + /// If the name is already normalized, returns the original string. + /// + private string GetPooledNormalizedName(string name) + { + if (!NeedsNormalization(name)) + return name; + + string normalized = name.ToLowerInvariant().TrimEnd('.'); + + // Try to get from pool, or add if not present + if (_stringPool.TryGetValue(normalized, out string? pooled)) + return pooled; + + // Limit pool size to prevent unbounded growth + if (_stringPool.Count < StringPoolMaxSize) + { + _stringPool.TryAdd(normalized, normalized); + } + + return normalized; + } + + /// + /// Checks if the domain name needs normalization (has uppercase or trailing dot). + /// + private static bool NeedsNormalization(string name) + { + if (name.Length > 0 && name[^1] == '.') + return true; + + foreach (char c in name) + { + if (c >= 'A' && c <= 'Z') + return true; + } + + return false; + } + + private static DomainInfo Parse(string name) + { + DomainParser? parser = _parser.Value; + if (parser == null) + return Empty; + + try + { + return parser.Parse(name) ?? Empty; + } + catch + { + // Parsing errors are intentionally ignored because PSL is optional. + return Empty; + } + } + + private static DomainParser? InitializeParser() + { + // ADR: The PSL download via SimpleHttpRuleProvider performs outbound HTTP. + // Relying on external network connectivity at plugin startup is unsafe in + // production DNS environments (offline appliances, firewalled networks, + // corporate proxies). Initialization must never block or fail due to PSL + // retrieval. We therefore treat PSL availability as optional: + // - If the download succeeds, domain parsing is enriched. + // - If it fails, we return null and logging continues without PSL data. + try + { + return new DomainParser(_sharedRuleProvider.Value); + } + catch + { + return null; + } + } + + private void AddToCache(string key, DomainInfo domain) + { + lock (_evictionLock) + { + if (_cache.ContainsKey(key)) + return; + + while (_cache.Count >= MaxSize) + Evict(); + + CacheNode newNode = new CacheNode(key, domain); + InsertAtHead(newNode); + _cache[key] = newNode; + } + } + + private void InsertAtHead(CacheNode node) + { + node.Next = _head; + node.Prev = null; + + if (_head != null) + _head.Prev = node; + + _head = node; + + _tail ??= node; + + _hand ??= node; + } + + private void Evict() + { + _hand ??= _tail; + + while (_hand != null) + { + if (!_hand.Visited) + { + CacheNode victim = _hand; + _hand = _hand.Prev ?? _tail; + RemoveNode(victim); + _cache.TryRemove(victim.Key, out _); + return; + } + + _hand.Visited = false; + _hand = _hand.Prev ?? _tail; + } + } + + private void RemoveNode(CacheNode node) + { + if (node.Prev != null) + node.Prev.Next = node.Next; + else + _head = node.Next; + + if (node.Next != null) + node.Next.Prev = node.Prev; + else + _tail = node.Prev; + + if (_hand == node) + _hand = node.Prev ?? _tail; + } + + private class CacheNode + { + public readonly string Key; + public readonly DomainInfo Domain; + public volatile bool Visited; + public CacheNode? Next; + public CacheNode? Prev; + + public CacheNode(string key, DomainInfo domain) + { + Key = key; + Domain = domain; + } + } + #endregion + } +} \ No newline at end of file diff --git a/Apps/TyposquattingDetector/README.md b/Apps/TyposquattingDetector/README.md new file mode 100644 index 000000000..b3ac1d2ea --- /dev/null +++ b/Apps/TyposquattingDetector/README.md @@ -0,0 +1,51 @@ +# Typosquatting Detector for Technitium DNS Server + +A DNS security plugin that detects and blocks look-alike domains associated with phishing and brand impersonation. The plugin evaluates similarity between queried domains and a high-reputation corpus and blocks near-miss variants before resolution. + +## Detection model + +The plugin builds a trusted corpus from the Majestic Million list plus an optional custom list. For each query it: + +1. Normalizes to the registrable domain using Public Suffix rules. +2. Performs an O(1) Bloom filter check for known legitimate domains. +3. Runs fuzzy similarity matching against length-adjacent candidates for unknown domains. + +Queries above the configured similarity threshold are classified as probable typosquats and blocked. + +## Enforcement behavior + +Suspicious domains receive an authoritative NXDOMAIN with SOA. Optional Extended DNS Error metadata and optional TXT blocking reports expose structured blocking details for logs and SIEM ingestion. Clean domains are not modified and resolve normally. + +## Configuration + +Example configuration: + +```json +{ + "enable": true, + "fuzzyMatchThreshold": 75, + "customList": "/path/to/custom-domains.txt", + "disableTlsValidation": false, + "updateInterval": "30d", + "allowTxtBlockingReport": true, + "addExtendedDnsError": true +} +``` + +Key options + +* fuzzyMatchThreshold (75–90): main sensitivity control. Lower values detect more variants but increase false positives. +* customList: one domain per line; add organization and brand domains you want treated as trusted. +* updateInterval: controls when the Majestic list is reprocessed; rebuilds are skipped when the file hash is unchanged. +* allowTxtBlockingReport / addExtendedDnsError: control operator visibility of blocking decisions. +* disableTlsValidation: test or lab use only. + +## Deployment and risk considerations + +Start with a conservative threshold (85–90) in production and observe blocks before lowering. False positives are most likely for domains visually similar to major brands but legitimate or newly emerging services. Mitigations include raising the threshold or adding the domain to the custom list. + +This plugin is intended for recursive resolvers operated by security teams where DNS blocking is an accepted control point. Communicate expected behavior to users and support staff to avoid confusion when NXDOMAIN is enforcement rather than resolution failure. + +## Acknowledgements + +Uses [Majestic Million dataset](https://majestic.com/reports/majestic-million), [Nager Public Suffix parser](https://github.com/nager/Nager.PublicSuffix), [BloomFilter.NetCore](https://github.com/vla/BloomFilter.NetCore) and [FuzzySharp](https://github.com/JakeBayer/FuzzySharp) libraries, and the Technitium DNS Server app framework. diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.MatchState.cs b/Apps/TyposquattingDetector/TyposquattingDetector.MatchState.cs new file mode 100644 index 000000000..7ed4192dd --- /dev/null +++ b/Apps/TyposquattingDetector/TyposquattingDetector.MatchState.cs @@ -0,0 +1,75 @@ +/* +Technitium DNS Server +Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com) +Copyright (C) 2025 Zafer Balkan (zafer@zaferbalkan.com) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Concurrent; +using System.Threading; + +namespace TyposquattingDetector +{ + public partial class TyposquattingDetector + { + // Bound the pool so burst traffic cannot cause permanent memory growth. + // Size heuristic: a few multiples of CPU is enough to cover typical concurrency. + private static readonly int MaxStatePoolSize = Math.Max(16, Environment.ProcessorCount * 4); + + private readonly ConcurrentQueue _statePool = new ConcurrentQueue(); + + private int _statePoolCount; + + private MatchState GetState() + { + if (_statePool.TryDequeue(out MatchState? state)) + { + Interlocked.Decrement(ref _statePoolCount); + return state; + } + + return new MatchState(); + } + + private void ReturnState(MatchState state) + { + state.Reset(); + + int newCount = Interlocked.Increment(ref _statePoolCount); + if (newCount <= MaxStatePoolSize) + { + _statePool.Enqueue(state); + return; + } + + // Over cap: undo the count and let GC reclaim this instance. + Interlocked.Decrement(ref _statePoolCount); + } + + // Define the state as a class to allow locking + private sealed class MatchState + { + public string? BestDomain; + public int BestScore; + + public void Reset() + { + BestDomain = null; + BestScore = 0; + } + } + } +} \ No newline at end of file diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.cs b/Apps/TyposquattingDetector/TyposquattingDetector.cs new file mode 100644 index 000000000..a52bd17e2 --- /dev/null +++ b/Apps/TyposquattingDetector/TyposquattingDetector.cs @@ -0,0 +1,447 @@ +/* +Technitium DNS Server +Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com) +Copyright (C) 2025 Zafer Balkan (zafer@zaferbalkan.com) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using BloomFilter; +using FuzzySharp; +using Nager.PublicSuffix; +using Nager.PublicSuffix.RuleProviders; +using Nager.PublicSuffix.RuleProviders.CacheProviders; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace TyposquattingDetector +{ + public enum Reason + { + Exact, + Typosquatting, + NoCandidates + } + + public enum Severity + { + NONE, + LOW, + MEDIUM, + HIGH + } + + public class Result + { + public Result(string query) + { + Query = query; + } + + public string? BestMatch { get; set; } + public int FuzzyScore { get; set; } + public bool IsSuspicious { get; set; } + public string Query { get; } + public Reason Reason { get; set; } + public Severity Severity { get; set; } = Severity.NONE; + } + + public partial class TyposquattingDetector : IDisposable + { + #region variables + + // Length -> (prefixKey -> candidates) + private readonly Dictionary>> _lenPrefixBuckets = new Dictionary>>(); + private const int MaxCandidatesPerPrefix2Bucket = 2000; // Tune caps to bound worst-case CPU per query + + // Prefix1 shards are broad (all domains sharing first char). Keep them small to save memory. + private const int MaxCandidatesPerPrefix1Bucket = 2000; + + // Only consult prefix1 when prefix2 didn't yield a strong candidate. + private const int Prefix1FallbackMinScore = 88; + + readonly DomainCache _domainCache; + private readonly ParallelOptions _po; + private readonly int _threshold; + private IBloomFilter? _bloomFilter; + + // Use sequential processing for smaller buckets; benchmarks showed that below ~256 + // candidates, the overhead of parallelism outweighs its benefits. + const int SequentialCutoff = 256; + + + private bool _disposedValue; + + #endregion variables + + #region constructor + + public TyposquattingDetector(string defaultPath, string customPath, int threshold) + { + _threshold = threshold; + _domainCache = new DomainCache(); + _po = new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2) }; + LoadData(defaultPath, customPath); + } + + #endregion constructor + + #region Dispose + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + _domainCache.Clear(); + } + _disposedValue = true; + } + } + + #endregion Dispose + + #region public + + public Result Check(string query) + { + string? normalized = Normalize(query); + if (normalized == null) + { + return new Result(query) + { + IsSuspicious = false, + Reason = Reason.NoCandidates + }; + } + + Result result = new Result(normalized); + + // GATE 1: Bloom Filter Prefilter (O(1)) + if (_bloomFilter is not null && _bloomFilter.Contains(normalized)) + { + result.IsSuspicious = false; + result.Reason = Reason.Exact; + return result; + } + + // GATE 2: Fuzzy Similarity Check + return FuzzyMatch(normalized, result); + } + + #endregion public + + #region private + + private static string? ExtractDomain(string line) + { + ReadOnlySpan span = line.AsSpan(); + int firstComma = span.IndexOf(','); + if (firstComma == -1) return null; + ReadOnlySpan afterFirst = span.Slice(firstComma + 1); + int secondComma = afterFirst.IndexOf(','); + if (secondComma == -1) return null; + ReadOnlySpan afterSecond = afterFirst.Slice(secondComma + 1); + int thirdComma = afterSecond.IndexOf(','); + return (thirdComma == -1 ? afterSecond : afterSecond.Slice(0, thirdComma)).ToString(); + } + + private static bool PassesPrefilter(string q, string d, int threshold) + { + if (string.IsNullOrEmpty(q) || string.IsNullOrEmpty(d)) + return false; + + int dl = d.Length; + int ql = q.Length; + + // reject far-length candidates + if (Math.Abs(dl - ql) > 2) + return false; + + // fast first-char rejection + if (q[0] != d[0]) + return false; + + // tiny strings → go straight to Fuzz() + if (ql < 4 || dl < 4) + return true; + + // small trigram overlap check (no alloc) + int hits = 0; + int maxTrigrams = Math.Min(10, Math.Min(ql, dl) - 2); + for (int i = 0; i < maxTrigrams; i++) + if (d.AsSpan().IndexOf(q.AsSpan(i, 3)) >= 0) hits++; + + // require minimal neighborhood similarity + return hits >= 1 || threshold <= 80; + } + + private Result FuzzyMatch(string query, Result result) + { + MatchState globalState = GetState(); + globalState.BestDomain = null; + globalState.BestScore = 0; + + uint q2 = Prefix2Key(query); + uint q1 = Prefix1Key(query); + + for (int delta = -1; delta <= 1; delta++) + { + int len = query.Length + delta; + + if (!_lenPrefixBuckets.TryGetValue(len, out var shardMap)) + continue; + + // 1) Exact prefix2 shard first (fastest / smallest) + if (shardMap.TryGetValue(q2, out var bucket2)) + { + if (bucket2.Count <= SequentialCutoff) + SequentialMatch(query, globalState, bucket2); + else + ParallelMatch(query, globalState, bucket2); + + if (globalState.BestScore >= 98) break; + } + + // 2) Prefix1 fallback shard (covers second-character differences) + // Only pay this cost if prefix2 didn't already produce a decent match. + if (globalState.BestScore < Prefix1FallbackMinScore && + q1 != q2 && + shardMap.TryGetValue(q1, out var bucket1)) + { + if (bucket1.Count <= SequentialCutoff) + SequentialMatch(query, globalState, bucket1); + else + ParallelMatch(query, globalState, bucket1); + + if (globalState.BestScore >= 98) break; + } + } + + if (globalState.BestDomain != null) + { + GetSuspiciousResult(result, globalState); + } + else + { + GetNormalResult(result); + } + ReturnState(globalState); + return result; + } + + private static uint Prefix2Key(string s) + { + if (string.IsNullOrEmpty(s)) return 0; + + char c0 = s[0]; + char c1 = s.Length > 1 ? s[1] : '\0'; + return (uint)c0 | ((uint)c1 << 16); + } + + private static uint Prefix1Key(string s) + { + if (string.IsNullOrEmpty(s)) return 0; + + char c0 = s[0]; + return (uint)c0; // equivalent to (uint)c0 | (0u << 16) + } + + private void AddToBucket(int len, uint key, string domain, int cap) + { + if (!_lenPrefixBuckets.TryGetValue(len, out var shardMap)) + { + shardMap = new Dictionary>(capacity: 128); + _lenPrefixBuckets[len] = shardMap; + } + + if (!shardMap.TryGetValue(key, out var list)) + { + // Small initial capacity; grows if needed but capped by `cap` + list = new List(capacity: Math.Min(256, cap)); + shardMap[key] = list; + } + + if (list.Count < cap) + list.Add(domain); + } + private static void GetNormalResult(Result result) + { + result.IsSuspicious = false; + result.Reason = Reason.NoCandidates; + } + + private static void GetSuspiciousResult(Result result, MatchState globalState) + { + result.BestMatch = globalState.BestDomain; + result.FuzzyScore = globalState.BestScore; + result.IsSuspicious = true; + result.Severity = globalState.BestScore > 85 ? Severity.HIGH : Severity.MEDIUM; + result.Reason = Reason.Typosquatting; + } + + private void LoadData(string oneMilFilePath, string customPath) + { + // Capacity for 1M domains + custom list + _bloomFilter = FilterBuilder.Build(1_100_000, 0.001); + + // Helper to add domains to both Bloom and Buckets + void processDomain(string domain) + { + if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrEmpty(domain)) return; + + domain = domain.ToLowerInvariant(); + _bloomFilter.Add(domain); + + int len = domain.Length; + + // Primary shard: prefix2 + uint p2 = Prefix2Key(domain); + AddToBucket(len, p2, domain, MaxCandidatesPerPrefix2Bucket); + + // Fallback shard: prefix1 (helps if the 2nd character differs) + // Note: capped low to bound memory + fallback is gated on score. + uint p1 = Prefix1Key(domain); + if (p1 != p2) + AddToBucket(len, p1, domain, MaxCandidatesPerPrefix1Bucket); + + } + + // 1. Load custom list + if (!string.IsNullOrEmpty(customPath) && File.Exists(customPath)) + { + foreach (string line in File.ReadLines(customPath)) + processDomain(line.Trim()); + } + + // 2. Load Majestic 1M + if (File.Exists(oneMilFilePath)) + { + using FileStream fs = new FileStream(oneMilFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024); + using StreamReader reader = new StreamReader(fs); + reader.ReadLine(); // Skip header + + while (reader.ReadLine() is { } line) + { + string? domain = ExtractDomain(line); + if (domain != null) processDomain(domain); + } + } + } + + private string? Normalize(string s) + { + if (string.IsNullOrWhiteSpace(s)) return null; + + try + { + string? rd = _domainCache.GetOrAdd(s)?.RegistrableDomain; + if (string.IsNullOrWhiteSpace(rd)) rd = s; + return rd.TrimEnd('.').ToLowerInvariant(); + } + catch + { + string? clean = s.Trim().TrimEnd('.').ToLowerInvariant(); + if (string.IsNullOrEmpty(clean)) return null; + ReadOnlySpan span = clean.AsSpan(); + if (span.StartsWith("www.".AsSpan())) span = span[4..]; + if (span.StartsWith("m.".AsSpan())) span = span[2..]; + return new string(span); + } + } + + private void ParallelMatch(string query, MatchState globalState, List bucket) + { + Parallel.ForEach( + bucket, + _po, + () => (score: 0, dom: (string?)null), // Thread-local state + (domain, state, local) => + { + // Volatile check for early exit + if (Volatile.Read(ref globalState.BestScore) >= 98) + { + state.Stop(); + return local; + } + + if (!PassesPrefilter(query, domain, _threshold)) + return local; + + int score = Fuzz.WeightedRatio(query, domain); + + if (score > local.score) + local = (score, domain); + + if (score >= 95) state.Stop(); + + return local; + }, + local => + { + // Reduction phase: Merge thread-local winner into global state + if (local.dom == null) + { + return; + } + lock (globalState) + { + if (local.score <= globalState.BestScore) + { + return; + } + globalState.BestScore = local.score; + globalState.BestDomain = local.dom; + } + } + ); + } + + private void SequentialMatch(string query, MatchState state, List bucket) + { + foreach (string domain in bucket) + { + if (state.BestScore >= 98) break; + + if (!PassesPrefilter(query, domain, _threshold)) + continue; + + int score = Fuzz.WeightedRatio(query, domain); + + if (score > state.BestScore) + { + state.BestScore = score; + state.BestDomain = domain; + } + + if (score >= 95) break; + } + } + + #endregion private + } +} \ No newline at end of file diff --git a/Apps/TyposquattingDetector/TyposquattingDetector.csproj b/Apps/TyposquattingDetector/TyposquattingDetector.csproj new file mode 100644 index 000000000..17acae3f0 --- /dev/null +++ b/Apps/TyposquattingDetector/TyposquattingDetector.csproj @@ -0,0 +1,51 @@ + + + + net9.0 + false + 1.0 + false + Technitium + Technitium DNS Server + Zafer Balkan + TyposquattingDetector + TyposquattingDetector + https://technitium.com/dns/ + https://github.com/TechnitiumSoftware/DnsServer + Evaluates queried domains against a trusted corpus and flags visually similar near-matches as potential typosquatting. Allows blocking of suspicious queries and exposes structured detection details. The fuzzy-match threshold and optional custom domain list are operator-tunable; adjust cautiously to reduce false-positive impact. + false + Library + true + enable + + + + + + + + + + + false + + + + + + ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.dll + false + + + ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.Net.dll + false + + + + + + PreserveNewest + + + + diff --git a/Apps/TyposquattingDetector/dnsApp.config b/Apps/TyposquattingDetector/dnsApp.config new file mode 100644 index 000000000..6a2e48095 --- /dev/null +++ b/Apps/TyposquattingDetector/dnsApp.config @@ -0,0 +1,9 @@ +{ + "enable": true, + "customList": "", + "disableTlsValidation": false, + "updateInterval": "30d", + "allowTxtBlockingReport": true, + "addExtendedDnsError": true, + "fuzzyMatchThreshold": 75 +} \ No newline at end of file diff --git a/DnsServer.sln b/DnsServer.sln index 0a2a6756d..d8bf9890e 100644 --- a/DnsServer.sln +++ b/DnsServer.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.0.32014.148 +VisualStudioVersion = 17.0.31912.275 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DnsServerApp", "DnsServerApp\DnsServerApp.csproj", "{ADE80805-9FA7-4F66-8A18-57B98F8C0B0F}" EndProject @@ -68,8 +68,11 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryLogsMySqlApp", "Apps\QueryLogsMySqlApp\QueryLogsMySqlApp.csproj", "{699E2A1D-D917-4825-939E-65CDB2B16A96}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MispConnectorApp", "Apps\MispConnectorApp\MispConnectorApp.csproj", "{83C8180A-0F86-F9A0-8F41-6FD61FAC41CB}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DnsServerCore.HttpApi", "DnsServerCore.HttpApi\DnsServerCore.HttpApi.csproj", "{1A49D371-D08C-475E-B7A2-6E8ECD181FD6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TyposquattingDetector", "Apps\TyposquattingDetector\TyposquattingDetector.csproj", "{FC71CB85-F69D-44E5-A447-52B39C7AB5C2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -208,6 +211,10 @@ Global {1A49D371-D08C-475E-B7A2-6E8ECD181FD6}.Debug|Any CPU.Build.0 = Debug|Any CPU {1A49D371-D08C-475E-B7A2-6E8ECD181FD6}.Release|Any CPU.ActiveCfg = Release|Any CPU {1A49D371-D08C-475E-B7A2-6E8ECD181FD6}.Release|Any CPU.Build.0 = Release|Any CPU + {FC71CB85-F69D-44E5-A447-52B39C7AB5C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC71CB85-F69D-44E5-A447-52B39C7AB5C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC71CB85-F69D-44E5-A447-52B39C7AB5C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC71CB85-F69D-44E5-A447-52B39C7AB5C2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -240,6 +247,7 @@ Global {6F655C97-FD43-4FE1-B15A-6C783D2D91C9} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} {699E2A1D-D917-4825-939E-65CDB2B16A96} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} {83C8180A-0F86-F9A0-8F41-6FD61FAC41CB} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} + {FC71CB85-F69D-44E5-A447-52B39C7AB5C2} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6747BB6D-2826-4356-A213-805FBCCF9201}