diff --git a/src/Wiki.Core/Parsing/LeakScanner.cs b/src/Wiki.Core/Parsing/LeakScanner.cs new file mode 100644 index 0000000..fa0272d --- /dev/null +++ b/src/Wiki.Core/Parsing/LeakScanner.cs @@ -0,0 +1,135 @@ +using System.Net; +using System.Text.RegularExpressions; + +namespace Wiki.Core.Parsing; + +/// A house identifier found in public block : the that matched and the matched text. +public sealed record LeakMatch(int BlockIndex, string Rule, string Value); + +/// +/// Deterministic leak check over the public blocks of an article body. A candidate is a leak when it occurs +/// (case-insensitively) in the article's source notes or is a renner.uno name; reserved example +/// values (documentation address ranges, example.* names) never are, so generic examples stay public. +/// Inline code spans are scanned like any other text. +/// +public static partial class LeakScanner +{ + private static readonly string[] HostSuffixes = [".renner.uno", ".local", ".lan", ".internal"]; + private static readonly string[] ExampleNames = ["example.com", "example.org", "example.net"]; + + public static IReadOnlyList Scan(string body, IReadOnlyList sources) + { + var result = new List(); + foreach (var block in ArticleBody.PublicBlocks(body)) + { + var seen = new HashSet<(string, string)>(); + foreach (var (rule, value) in Candidates(block.Text)) + { + if (!IsHouseValue(rule, value, sources)) continue; + if (seen.Add((rule, value.ToLowerInvariant()))) result.Add(new LeakMatch(block.Index, rule, value)); + } + } + return result; + } + + /// Wraps every public block with at least one match in a private block. + public static string PrivatizeLeaks(string body, IReadOnlyList sources) + { + var indexes = Scan(body, sources).Select(m => m.BlockIndex).ToHashSet(); + return indexes.Count == 0 ? body : ArticleBody.Privatize(body, indexes); + } + + private static bool IsHouseValue(string rule, string value, IReadOnlyList sources) + { + if (IsReserved(rule, value)) return false; + if (rule == "fqdn" && IsRennerUno(value)) return true; + return sources.Any(s => s.Contains(value, StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsRennerUno(string host) => + host.Equals("renner.uno", StringComparison.OrdinalIgnoreCase) + || host.EndsWith(".renner.uno", StringComparison.OrdinalIgnoreCase); + + private static bool IsReserved(string rule, string value) + { + switch (rule) + { + case "ipv4" when IPAddress.TryParse(value, out var v4): + { + var b = v4.GetAddressBytes(); + return (b[0] == 192 && b[1] == 0 && b[2] == 2) + || (b[0] == 198 && b[1] == 51 && b[2] == 100) + || (b[0] == 203 && b[1] == 0 && b[2] == 113); + } + case "ipv6" when IPAddress.TryParse(value, out var v6): + { + var b = v6.GetAddressBytes(); + return b.Length == 16 && b[0] == 0x20 && b[1] == 0x01 && b[2] == 0x0d && b[3] == 0xb8; + } + case "fqdn": + return value.EndsWith(".example", StringComparison.OrdinalIgnoreCase) + || ExampleNames.Any(n => value.Equals(n, StringComparison.OrdinalIgnoreCase) + || value.EndsWith("." + n, StringComparison.OrdinalIgnoreCase)); + default: + return false; + } + } + + private static IEnumerable<(string Rule, string Value)> Candidates(string text) + { + foreach (Match m in Ipv4Pattern().Matches(text)) + { + if (IPAddress.TryParse(m.Value, out var ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + yield return ("ipv4", m.Value); + } + foreach (Match m in Ipv6Pattern().Matches(text)) + { + if (m.Value.Any(Uri.IsHexDigit) && IPAddress.TryParse(m.Value, out var ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) + yield return ("ipv6", m.Value); + } + foreach (Match m in MacPattern().Matches(text)) + yield return ("mac", m.Value); + foreach (Match m in FqdnPattern().Matches(text)) + { + if (IsFqdnCandidate(m.Value)) yield return ("fqdn", m.Value); + } + foreach (Match m in EntityIdPattern().Matches(text)) + yield return ("entity-id", m.Value); + foreach (Match m in PathPattern().Matches(text)) + yield return ("path", m.Value); + foreach (var pattern in new[] { SecretPathPattern(), VaultPattern() }) + { + foreach (Match m in pattern.Matches(text)) + yield return ("secret-path", m.Value); + } + } + + private static bool IsFqdnCandidate(string name) => + name.Count(c => c == '.') >= 2 + || HostSuffixes.Any(s => name.EndsWith(s, StringComparison.OrdinalIgnoreCase)) + || IsRennerUno(name); + + [GeneratedRegex(@"(? m.Rule == rule && m.BlockIndex == 0); + } + + [Fact] + public void Scan_InlineCodeSpan_IsScanned() + { + var matches = LeakScanner.Scan("Host `nas-1.lan` ist da", ["nas-1.lan"]); + Assert.Contains(matches, m => m.Rule == "fqdn"); + } + + [Fact] + public void Scan_IgnoresPrivateBlocks() + { + var matches = LeakScanner.Scan("::: private\nDer Server 10.2.40.1 antwortet\n:::", ["10.2.40.1"]); + Assert.Empty(matches); + } + + [Fact] + public void Scan_GenericExampleNotInSources_NoMatch() + { + var matches = LeakScanner.Scan("Eine Entität heißt zum Beispiel light.wohnzimmer", ["light.eg_kuche_ambiente"]); + Assert.Empty(matches); + } + + [Fact] + public void Scan_MatchIsCaseInsensitive() + { + var matches = LeakScanner.Scan("LIGHT.EG_KUCHE_AMBIENTE", ["light.eg_kuche_ambiente"]); + Assert.Contains(matches, m => m.Rule == "entity-id"); + } + + [Theory] + [InlineData("192.0.2.10")] + [InlineData("198.51.100.7")] + [InlineData("203.0.113.9")] + [InlineData("2001:db8::1")] + [InlineData("nas.example.com")] + [InlineData("a.b.example")] + public void Scan_ReservedExample_NoMatch(string value) + { + Assert.Empty(LeakScanner.Scan($"Beispiel {value} hier", [value])); + } + + [Fact] + public void Scan_RennerUno_AlwaysMatches() + { + var matches = LeakScanner.Scan("gitea.renner.uno", []); + Assert.Contains(matches, m => m.Rule == "fqdn"); + Assert.Contains(LeakScanner.Scan("Domain renner.uno", []), m => m.Rule == "fqdn"); + } + + [Fact] + public void Scan_NoFalsePositive_OnPlainProse() + { + var text = "Die Wärmepumpe heizt den Estrich. Version 2.5 ist aktuell."; + Assert.Empty(LeakScanner.Scan(text, [text, "2.5"])); + } + + [Fact] + public void PrivatizeLeaks_MovesOnlyLeakingBlock() + { + var body = "Allgemeiner Text.\n\nDer Server 10.2.40.1 antwortet.\n\nNoch ein Absatz."; + var result = LeakScanner.PrivatizeLeaks(body, ["10.2.40.1"]); + Assert.Equal("Allgemeiner Text.\n\n::: private\nDer Server 10.2.40.1 antwortet.\n:::\n\nNoch ein Absatz.", result); + } + + [Fact] + public void PrivatizeLeaks_NoMatch_ReturnsBodyUnchanged() + { + Assert.Equal("Nur Text.", LeakScanner.PrivatizeLeaks("Nur Text.", [])); + } +}