Skip to content

Commit 06a5b0a

Browse files
committed
Harden redaction tests (URL fragment tokens, compressed IPv6, quoted conn-string passwords, epoch-ms negatives) and strengthen redactors to match
1 parent bafaa73 commit 06a5b0a

11 files changed

+120
-13
lines changed

src/KeelMatrix.QueryWatch/Redaction/ConnectionStringPasswordRedactor.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
namespace KeelMatrix.QueryWatch.Redaction {
55
/// <summary>
66
/// Masks password values inside connection strings (Password=...; Pwd=...; forms).
7+
/// Supports quoted values so that <c>Password="sec;ret;value"</c> is fully masked.
78
/// </summary>
89
public sealed class ConnectionStringPasswordRedactor : IQueryTextRedactor {
910
private static readonly Regex Pw = new(
10-
@"(?i)\b(Password|Pwd)\s*=\s*([^;]+)",
11+
// key (group 1), "=", then either a quoted value (single or double) or an unquoted value up to the next semicolon
12+
@"\b(Password|Pwd)\s*=\s*(?:""[^""]*""|'[^']*'|[^;]+)",
1113
RegexOptions.Compiled | RegexOptions.CultureInvariant);
1214

1315
/// <inheritdoc />
14-
public string Redact(string input) => string.IsNullOrEmpty(input) ? string.Empty : Pw.Replace(input, m => m.Groups[1].Value + "=***");
16+
public string Redact(string input)
17+
=> string.IsNullOrEmpty(input) ? string.Empty : Pw.Replace(input, m => m.Groups[1].Value + "=***");
1518
}
1619
}

src/KeelMatrix.QueryWatch/Redaction/IpAddressRedactor.cs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,33 @@
33

44
namespace KeelMatrix.QueryWatch.Redaction {
55
/// <summary>
6-
/// Masks IPv4 and (conservatively) IPv6 addresses.
6+
/// Masks IPv4 and IPv6 addresses, including IPv6 compressed forms like <c>::1</c>.
7+
/// Uses lookarounds instead of word boundaries so addresses starting with ':' are matched.
78
/// </summary>
89
public sealed class IpAddressRedactor : IQueryTextRedactor {
910
private static readonly Regex IPv4 = new(
1011
@"\b(?:(?:25[0-5]|2[0-4]\d|1?\d{1,2})\.){3}(?:25[0-5]|2[0-4]\d|1?\d{1,2})\b",
1112
RegexOptions.Compiled | RegexOptions.CultureInvariant);
1213

13-
// Conservative IPv6: 2-7 colons, simple hextets (does not try to match every edge case like :: compression).
14-
private static readonly Regex IPv6 = new(
15-
@"\b(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}\b",
14+
// IPv6 with '::' compression (covers ::1, fe80::1, 2001:db8::7334, etc.)
15+
private static readonly Regex IPv6Compressed = new(
16+
@"(?<![A-Fa-f0-9:])(?:[A-Fa-f0-9]{1,4}(?::[A-Fa-f0-9]{1,4}){0,5})?::(?:[A-Fa-f0-9]{1,4}(?::[A-Fa-f0-9]{1,4}){0,5})(?![A-Fa-f0-9:])",
17+
RegexOptions.Compiled | RegexOptions.CultureInvariant);
18+
19+
// IPv6 without '::' compression (2–8 hextets)
20+
private static readonly Regex IPv6Full = new(
21+
@"(?<![A-Fa-f0-9:])(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}(?![A-Fa-f0-9:])",
1622
RegexOptions.Compiled | RegexOptions.CultureInvariant);
1723

18-
/// <inheritdoc />
1924
public string Redact(string input) {
20-
if (string.IsNullOrEmpty(input)) return string.Empty;
21-
var r = IPv4.Replace(input, "***");
22-
r = IPv6.Replace(r, "***");
23-
return r;
25+
if (string.IsNullOrEmpty(input))
26+
return string.Empty;
27+
28+
var result = IPv4.Replace(input, "***");
29+
// Replace compressed first to ensure leading '::' forms are handled
30+
result = IPv6Compressed.Replace(result, "***");
31+
result = IPv6Full.Replace(result, "***");
32+
return result;
2433
}
2534
}
2635
}

src/KeelMatrix.QueryWatch/Redaction/UrlQueryTokenRedactor.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
namespace KeelMatrix.QueryWatch.Redaction {
55
/// <summary>
66
/// Masks sensitive URL query parameters like token, access_token, code, id_token, auth.
7+
/// Also handles tokens present in URL fragments (after <c>#</c>), which are common in OAuth implicit flows.
78
/// </summary>
89
public sealed class UrlQueryTokenRedactor : IQueryTextRedactor {
910
private static readonly Regex Param = new(
10-
@"(?i)(?<=[\?&])(token|access_token|code|id_token|auth)=([^&#\s]+)",
11+
// the parameter name (group 1) must be preceded by ?, &, or #; value runs until next & or # or whitespace
12+
@"(?i)(?:(?<=[\?&])|(?<=#))(token|access_token|code|id_token|auth)=([^&#\s]+)",
1113
RegexOptions.Compiled | RegexOptions.CultureInvariant);
1214

1315
/// <inheritdoc />
14-
public string Redact(string input) => string.IsNullOrEmpty(input) ? string.Empty : Param.Replace(input, m => m.Groups[1].Value + "=***");
16+
public string Redact(string input)
17+
=> string.IsNullOrEmpty(input) ? string.Empty : Param.Replace(input, m => m.Groups[1].Value + "=***");
1518
}
1619
}

tests/KeelMatrix.QueryWatch.Tests/Redaction/ApiKeyRedactorTests.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
using FluentAssertions;
23
using KeelMatrix.QueryWatch.Redaction;
34
using Xunit;
@@ -46,5 +47,12 @@ public void Idempotent_Application() {
4647
var twice = r.Redact(once);
4748
twice.Should().Be(once);
4849
}
50+
51+
[Fact]
52+
public void Masks_Query_Param_With_Hyphen_Or_Underscore() {
53+
var r = new ApiKeyRedactor();
54+
r.Redact("https://ex.com?api-key=XYZ").Should().Be("https://ex.com?api-key=***");
55+
r.Redact("https://ex.com?api_key=XYZ").Should().Be("https://ex.com?api_key=***");
56+
}
4957
}
5058
}

tests/KeelMatrix.QueryWatch.Tests/Redaction/ConnectionStringPasswordRedactorTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,19 @@ public void Leaves_Strings_Without_Password_Unchanged() {
2121
var input = "Server=.;Integrated Security=true;";
2222
r.Redact(input).Should().Be(input);
2323
}
24+
25+
[Fact]
26+
public void Masks_Quoted_Passwords_With_Semicolons_Inside() {
27+
var r = new ConnectionStringPasswordRedactor();
28+
var input1 = "Password=\"sec;ret;value\";User Id=sa;";
29+
var red1 = r.Redact(input1);
30+
red1.Should().Contain("Password=***;");
31+
red1.Should().NotContain("sec;ret;value");
32+
33+
var input2 = "Pwd='p;a;s;s';Server=.;";
34+
var red2 = r.Redact(input2);
35+
red2.Should().Contain("Pwd=***;");
36+
red2.Should().NotContain("p;a;s;s");
37+
}
2438
}
2539
}

tests/KeelMatrix.QueryWatch.Tests/Redaction/EmailRedactorTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,23 @@ public void Leaves_Text_Without_Emails() {
1818
var input = "SELECT 1;";
1919
r.Redact(input).Should().Be(input);
2020
}
21+
22+
[Fact]
23+
public void Handles_Plus_Tagging_And_Trailing_Punctuation() {
24+
var r = new EmailRedactor();
25+
var input = "Please email Admin+test@Example.COM, thanks.";
26+
var red = r.Redact(input);
27+
red.Should().NotContain("Admin+test@Example.COM");
28+
red.Should().Contain("***, thanks.");
29+
}
30+
31+
[Fact]
32+
public void Idempotent_For_Emails() {
33+
var r = new EmailRedactor();
34+
var input = "user@example.com";
35+
var once = r.Redact(input);
36+
var twice = r.Redact(once);
37+
twice.Should().Be(once);
38+
}
2139
}
2240
}

tests/KeelMatrix.QueryWatch.Tests/Redaction/GoogleApiKeyRedactorTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,12 @@ public void Masks_ApiKey_With_AIZa_Prefix() {
1313
var red = r.Redact(input);
1414
red.Should().NotContain(key).And.Contain("***");
1515
}
16+
17+
[Fact]
18+
public void Does_Not_Mask_When_Length_Is_Wrong() {
19+
var r = new GoogleApiKeyRedactor();
20+
var key = "AIza" + new string('B', 34); // one char short
21+
r.Redact(key).Should().Be(key);
22+
}
1623
}
1724
}

tests/KeelMatrix.QueryWatch.Tests/Redaction/IpAddressRedactorTests.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,18 @@ public void Leaves_Non_IP_Text() {
2121
var r = new IpAddressRedactor();
2222
r.Redact("x:y").Should().Be("x:y");
2323
}
24+
25+
[Fact]
26+
public void Masks_IPv6_Compressed_Forms() {
27+
var r = new IpAddressRedactor();
28+
r.Redact("addr=::1").Should().Be("addr=***");
29+
r.Redact("addr=2001:db8::7334").Should().Be("addr=***");
30+
}
31+
32+
[Fact]
33+
public void Masks_IPv4_With_Port_Keeping_Port() {
34+
var r = new IpAddressRedactor();
35+
r.Redact("host=192.168.0.1:8080").Should().Be("host=***:8080");
36+
}
2437
}
2538
}

tests/KeelMatrix.QueryWatch.Tests/Redaction/RegexReplaceRedactorTests.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,13 @@ public void Replaces_Matches_Using_Pattern() {
99
var r = new RegexReplaceRedactor(@"\d+", "***");
1010
r.Redact("Id=123; Name='A'").Should().Be("Id=***; Name='A'");
1111
}
12+
13+
[Fact]
14+
public void Supports_Precompiled_Regex_And_Handles_Null_Input() {
15+
var compiled = new System.Text.RegularExpressions.Regex("foo", System.Text.RegularExpressions.RegexOptions.Compiled | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
16+
var r = new RegexReplaceRedactor(compiled, "***");
17+
r.Redact("FOO bar").Should().Be("*** bar");
18+
r.Redact(null!).Should().Be(string.Empty);
19+
}
1220
}
1321
}

tests/KeelMatrix.QueryWatch.Tests/Redaction/TimestampRedactorTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,19 @@ public void Masks_Unix_Seconds_10_11_Digits() {
2020
red.Should().NotContain("1726750000");
2121
red.Should().NotContain("17267500001");
2222
}
23+
24+
[Fact]
25+
public void Does_Not_Mask_Unix_Milliseconds_13_Digits() {
26+
var r = new TimestampRedactor();
27+
var input = "ts=1726750000000"; // 13 digits (ms), should not be treated as seconds
28+
r.Redact(input).Should().Be(input);
29+
}
30+
31+
[Fact]
32+
public void Does_Not_Mask_Too_Short_Unix_Seconds() {
33+
var r = new TimestampRedactor();
34+
var input = "ts=123456789"; // 9 digits
35+
r.Redact(input).Should().Be(input);
36+
}
2337
}
2438
}

0 commit comments

Comments
 (0)