feat(core): deterministic leak scanner for public article text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Wiki.Core.Parsing;
|
||||
|
||||
/// <summary>A house identifier found in public block <see cref="BlockIndex"/>: the <see cref="Rule"/> that matched and the matched text.</summary>
|
||||
public sealed record LeakMatch(int BlockIndex, string Rule, string Value);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>renner.uno</c> name; reserved example
|
||||
/// values (documentation address ranges, <c>example.*</c> names) never are, so generic examples stay public.
|
||||
/// Inline code spans are scanned like any other text.
|
||||
/// </summary>
|
||||
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<LeakMatch> Scan(string body, IReadOnlyList<string> sources)
|
||||
{
|
||||
var result = new List<LeakMatch>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Wraps every public block with at least one match in a private block.</summary>
|
||||
public static string PrivatizeLeaks(string body, IReadOnlyList<string> 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<string> 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(@"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?!\.?\d)", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex Ipv4Pattern();
|
||||
|
||||
[GeneratedRegex(@"(?<![\w:.])[0-9a-f]{0,4}(?::[0-9a-f]{0,4}){2,7}(?![\w:])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex Ipv6Pattern();
|
||||
|
||||
[GeneratedRegex(@"(?<![\w:-])(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}(?![\w:-])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex MacPattern();
|
||||
|
||||
[GeneratedRegex(@"(?<![\w.@-])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?![\w-])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex FqdnPattern();
|
||||
|
||||
[GeneratedRegex(@"(?<!\w)(?:light|switch|sensor|binary_sensor|cover|climate|script|automation|scene|input_\w+|button|number|select|fan|media_player|person|device_tracker|lock|alarm_control_panel|camera|vacuum|water_heater|update)\.[a-z0-9_]+(?![\w])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex EntityIdPattern();
|
||||
|
||||
[GeneratedRegex(@"(?<![\w/.:~-])(?:~|\.)?/[\w.@%+=-]+(?:/[\w.@%+=-]*)+", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex PathPattern();
|
||||
|
||||
[GeneratedRegex(@"(?<![\w/])(?:apps/[\w.-]+/[\w.-]+|(?:secret|kv)/[\w.-]+(?:/[\w.-]+)*)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SecretPathPattern();
|
||||
|
||||
[GeneratedRegex(@"(?<!\w)vault_[a-z_]+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex VaultPattern();
|
||||
}
|
||||
Reference in New Issue
Block a user