From 0b66d5a10c900d00634ef2691e90682781ac3191 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 10:25:05 -0700 Subject: [PATCH] Expand token sanitizer tests Expand TokenSanitizer coverage and simplify ExecApprovalPolicy.Save() to serialize the same defensive snapshot used by GetPolicyData().\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Shared/ExecApprovalPolicy.cs | 10 +- .../TokenSanitizerTests.cs | 176 ++++++++++++++++++ 2 files changed, 178 insertions(+), 8 deletions(-) diff --git a/src/OpenClaw.Shared/ExecApprovalPolicy.cs b/src/OpenClaw.Shared/ExecApprovalPolicy.cs index 6f08273..7dbeda2 100644 --- a/src/OpenClaw.Shared/ExecApprovalPolicy.cs +++ b/src/OpenClaw.Shared/ExecApprovalPolicy.cs @@ -249,14 +249,8 @@ public class ExecApprovalPolicy var dir = Path.GetDirectoryName(_policyFilePath); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); - - var data = new ExecPolicyData - { - DefaultAction = _defaultAction, - Rules = _rules - }; - - var json = JsonSerializer.Serialize(data, _jsonOptions); + + var json = JsonSerializer.Serialize(GetPolicyData(), _jsonOptions); File.WriteAllText(_policyFilePath, json); } catch (Exception ex) diff --git a/tests/OpenClaw.Shared.Tests/TokenSanitizerTests.cs b/tests/OpenClaw.Shared.Tests/TokenSanitizerTests.cs index eb69fd4..a336bfd 100644 --- a/tests/OpenClaw.Shared.Tests/TokenSanitizerTests.cs +++ b/tests/OpenClaw.Shared.Tests/TokenSanitizerTests.cs @@ -4,6 +4,29 @@ namespace OpenClaw.Shared.Tests; public class TokenSanitizerTests { + // ── null / empty input ───────────────────────────────────────────────── + + [Fact] + public void Sanitize_NullInput_ReturnsEmptyString() + { + Assert.Equal(string.Empty, TokenSanitizer.Sanitize(null)); + } + + [Fact] + public void Sanitize_EmptyInput_ReturnsEmptyString() + { + Assert.Equal(string.Empty, TokenSanitizer.Sanitize("")); + } + + [Fact] + public void Sanitize_NoSecrets_ReturnsSameString() + { + const string harmless = "Hello, world! This log message has no secrets."; + Assert.Equal(harmless, TokenSanitizer.Sanitize(harmless)); + } + + // ── Authorization: Bearer ────────────────────────────────────────────── + [Fact] public void Sanitize_RedactsAuthorizationBearerHeader() { @@ -13,6 +36,39 @@ public class TokenSanitizerTests Assert.Contains("Authorization: Bearer [REDACTED]", sanitized); } + [Theory] + [InlineData("AUTHORIZATION: BEARER my-token-value", "AUTHORIZATION: BEARER [REDACTED]")] + [InlineData("authorization: bearer my-token-value", "authorization: bearer [REDACTED]")] + [InlineData("Authorization:Bearer my-token-value", "Authorization:Bearer [REDACTED]")] + [InlineData("Authorization : Bearer my-token-value", "Authorization : Bearer [REDACTED]")] + public void Sanitize_BearerHeader_CaseAndSpacingVariants(string input, string expectedResult) + { + Assert.Equal(expectedResult, TokenSanitizer.Sanitize(input)); + } + + [Fact] + public void Sanitize_BearerToken_StopsAtWhitespace() + { + var sanitized = TokenSanitizer.Sanitize("Authorization: Bearer abc123 other text continues here"); + + Assert.Contains("[REDACTED]", sanitized); + Assert.Contains("other text continues here", sanitized); + Assert.DoesNotContain("abc123", sanitized); + } + + [Fact] + public void Sanitize_BearerInLog_RedactsTokenOnly() + { + var input = "2024-01-15 Sending request with Authorization: Bearer tok-secret remaining-log-context"; + var sanitized = TokenSanitizer.Sanitize(input); + + Assert.DoesNotContain("tok-secret", sanitized); + Assert.Contains("2024-01-15", sanitized); + Assert.Contains("remaining-log-context", sanitized); + } + + // ── JSON secret fields ───────────────────────────────────────────────── + [Fact] public void Sanitize_RedactsJsonTokenFields() { @@ -22,6 +78,46 @@ public class TokenSanitizerTests Assert.Contains(""""other":"visible"""", sanitized); } + [Theory] + [InlineData("token", """{"token":"my-secret"}""")] + [InlineData("secret", """{"secret":"my-secret"}""")] + [InlineData("bearer", """{"bearer":"my-secret"}""")] + [InlineData("authorization", """{"authorization":"my-secret"}""")] + [InlineData("access_token", """{"access_token":"my-secret"}""")] + [InlineData("client_secret", """{"client_secret":"my-secret"}""")] + [InlineData("BEARER_TOKEN", """{"BEARER_TOKEN":"my-secret"}""")] + public void Sanitize_JsonFieldsContainingKeyword_AreRedacted(string key, string input) + { + var sanitized = TokenSanitizer.Sanitize(input); + + Assert.DoesNotContain("my-secret", sanitized); + Assert.Contains(key, sanitized, StringComparison.OrdinalIgnoreCase); + Assert.Contains("[REDACTED]", sanitized); + } + + [Fact] + public void Sanitize_JsonFieldWithoutSecretKeyword_IsNotRedacted() + { + var sanitized = TokenSanitizer.Sanitize("""{"username":"alice","email":"alice@example.com"}"""); + + Assert.Contains("alice", sanitized); + Assert.DoesNotContain("[REDACTED]", sanitized); + } + + [Fact] + public void Sanitize_MultipleJsonSecretFields_AllRedacted() + { + var input = """{"token":"tok1","secret":"sec1","name":"alice","authorization":"auth1"}"""; + var sanitized = TokenSanitizer.Sanitize(input); + + Assert.DoesNotContain("tok1", sanitized); + Assert.DoesNotContain("sec1", sanitized); + Assert.DoesNotContain("auth1", sanitized); + Assert.Contains("alice", sanitized); + } + + // ── Long base64-url token shape ──────────────────────────────────────── + [Fact] public void Sanitize_RedactsBareMcpTokenShape() { @@ -31,4 +127,84 @@ public class TokenSanitizerTests Assert.DoesNotContain(token, sanitized); Assert.Contains("[REDACTED_TOKEN]", sanitized); } + + [Fact] + public void Sanitize_TokenAtStartOfString_IsRedacted() + { + var token = new string('x', 43); + var sanitized = TokenSanitizer.Sanitize($"{token} suffix"); + + Assert.DoesNotContain(token, sanitized); + Assert.Contains("[REDACTED_TOKEN]", sanitized); + Assert.Contains("suffix", sanitized); + } + + [Fact] + public void Sanitize_TokenAtEndOfString_IsRedacted() + { + var token = new string('Z', 43); + var sanitized = TokenSanitizer.Sanitize($"prefix {token}"); + + Assert.DoesNotContain(token, sanitized); + Assert.Contains("prefix", sanitized); + Assert.Contains("[REDACTED_TOKEN]", sanitized); + } + + [Fact] + public void Sanitize_ShortToken_NotRedacted() + { + // The pattern requires exactly 43 chars; 42-char sequences are NOT redacted. + var shortToken = new string('A', 42); + var sanitized = TokenSanitizer.Sanitize($"token is {shortToken} here"); + + Assert.Contains(shortToken, sanitized); + Assert.DoesNotContain("[REDACTED_TOKEN]", sanitized); + } + + [Fact] + public void Sanitize_LongerToken44Chars_NotRedacted() + { + // 44-char sequences are not matched (pattern anchors at exactly 43 within word boundaries). + var longToken = new string('A', 44); + var sanitized = TokenSanitizer.Sanitize($"token is {longToken} here"); + + Assert.Contains(longToken, sanitized); + } + + [Fact] + public void Sanitize_MultipleTokensInSameString_AllRedacted() + { + var t1 = new string('A', 43); + var t2 = new string('B', 43); + var sanitized = TokenSanitizer.Sanitize($"first={t1} second={t2}"); + + Assert.DoesNotContain(t1, sanitized); + Assert.DoesNotContain(t2, sanitized); + Assert.Equal(2, CountOccurrences(sanitized, "[REDACTED_TOKEN]")); + } + + // ── combinations ─────────────────────────────────────────────────────── + + [Fact] + public void Sanitize_BearerAndJsonInSameString_BothRedacted() + { + var input = """Authorization: Bearer tok123 {"apiToken":"api-secret"}"""; + var sanitized = TokenSanitizer.Sanitize(input); + + Assert.DoesNotContain("tok123", sanitized); + Assert.DoesNotContain("api-secret", sanitized); + Assert.Contains("[REDACTED]", sanitized); + } + + private static int CountOccurrences(string source, string pattern) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += pattern.Length; + } + return count; + } }