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.", []));
+ }
+}