Skip to content

Commit 428da07

Browse files
authored
feat(data-protection): intelligent sensitive data detection and redaction (#15)
1 parent 91bce33 commit 428da07

3 files changed

Lines changed: 376 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ control plane, or runtime AI filter.
1313

1414
- `src/Bower.Contracts`: immutable event contracts and serialization.
1515
- `src/Bower.PolicyEngine`: deterministic, explainable event selection.
16-
- `src/Bower.Redaction`: local data minimisation before persistence.
16+
- `src/Bower.Redaction`: local data minimisation and intelligent sensitive-data detection before persistence.
1717
- `src/Bower.Persistence`: SQLite queue, cursors, delivery state and evidence.
1818
- `src/Bower.Core`: source-to-policy-to-queue orchestration.
1919
- `src/Bower.Sdk`: semantic developer API and transports.
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
using System.Text;
2+
using System.Text.Json;
3+
using System.Text.Json.Nodes;
4+
using System.Text.RegularExpressions;
5+
using Bower.Abstractions;
6+
7+
namespace Bower.Redaction;
8+
9+
public enum SensitiveFindingKind
10+
{
11+
AwsAccessKey,
12+
AwsSecretKey,
13+
PrivateKeyBlock,
14+
Jwt,
15+
CreditCard,
16+
Email,
17+
IpAddress,
18+
BearerToken,
19+
ConnectionString,
20+
GenericSecret
21+
}
22+
23+
public sealed record SensitiveFinding(
24+
SensitiveFindingKind Kind,
25+
string Path,
26+
string Preview,
27+
string Action);
28+
29+
public sealed record SensitiveScanResult(
30+
bool Succeeded,
31+
string? RedactedJson,
32+
IReadOnlyList<SensitiveFinding> Findings,
33+
string? FailureCode);
34+
35+
public sealed partial class SensitiveDataDetector
36+
{
37+
public const int MaximumPayloadBytes = 1_048_576;
38+
39+
// Instance API mirrors JsonEventRedactor for DI wiring.
40+
private readonly int maximumPayloadBytes = MaximumPayloadBytes;
41+
42+
public SensitiveScanResult ScanAndRedact(string json, bool maskInPlace = true)
43+
{
44+
if (string.IsNullOrWhiteSpace(json))
45+
{
46+
return new SensitiveScanResult(false, null, [], "empty-payload");
47+
}
48+
49+
if (Encoding.UTF8.GetByteCount(json) > maximumPayloadBytes)
50+
{
51+
return new SensitiveScanResult(false, null, [], "payload-too-large");
52+
}
53+
54+
try
55+
{
56+
JsonNode? root = JsonNode.Parse(
57+
json,
58+
documentOptions: new JsonDocumentOptions
59+
{
60+
AllowTrailingCommas = false,
61+
CommentHandling = JsonCommentHandling.Disallow,
62+
MaxDepth = 32
63+
});
64+
if (root is not JsonObject rootObject)
65+
{
66+
return new SensitiveScanResult(false, null, [], "root-must-be-object");
67+
}
68+
69+
List<SensitiveFinding> findings = [];
70+
Walk(rootObject, "$", findings, maskInPlace);
71+
return new SensitiveScanResult(true, rootObject.ToJsonString(), findings, null);
72+
}
73+
catch (JsonException)
74+
{
75+
return new SensitiveScanResult(false, null, [], "invalid-json");
76+
}
77+
}
78+
79+
public RedactionResult ToRedactionResult(string json)
80+
{
81+
SensitiveScanResult scan = ScanAndRedact(json, maskInPlace: true);
82+
if (!scan.Succeeded || scan.RedactedJson is null)
83+
{
84+
return new RedactionResult(false, null, [], [], scan.FailureCode);
85+
}
86+
87+
List<string> removed = scan.Findings
88+
.Where(item => item.Action == "removed")
89+
.Select(item => item.Path)
90+
.ToList();
91+
List<string> masked = scan.Findings
92+
.Where(item => item.Action == "masked")
93+
.Select(item => item.Path)
94+
.ToList();
95+
return new RedactionResult(true, scan.RedactedJson, removed, masked, null);
96+
}
97+
98+
private static void Walk(
99+
JsonObject value,
100+
string parentPath,
101+
List<SensitiveFinding> findings,
102+
bool maskInPlace)
103+
{
104+
foreach ((string propertyName, JsonNode? child) in value.ToArray())
105+
{
106+
string path = $"{parentPath}.{propertyName}";
107+
string normalized = Normalize(propertyName);
108+
109+
if (IsSecretName(normalized))
110+
{
111+
findings.Add(
112+
new SensitiveFinding(
113+
SensitiveFindingKind.GenericSecret,
114+
path,
115+
Preview(child?.ToString()),
116+
"removed"));
117+
if (maskInPlace)
118+
{
119+
value.Remove(propertyName);
120+
}
121+
122+
continue;
123+
}
124+
125+
if (child is JsonValue jsonValue && jsonValue.TryGetValue(out string? text) && text is not null)
126+
{
127+
foreach (SensitiveFinding finding in DetectInText(path, text))
128+
{
129+
findings.Add(finding);
130+
if (maskInPlace && finding.Action == "masked")
131+
{
132+
value[propertyName] = MaskValue(text, finding.Kind);
133+
}
134+
}
135+
136+
continue;
137+
}
138+
139+
if (child is JsonObject childObject)
140+
{
141+
Walk(childObject, path, findings, maskInPlace);
142+
}
143+
else if (child is JsonArray array)
144+
{
145+
for (int index = 0; index < array.Count; index++)
146+
{
147+
if (array[index] is JsonObject nested)
148+
{
149+
Walk(nested, $"{path}[{index}]", findings, maskInPlace);
150+
}
151+
else if (array[index] is JsonValue arrayValue &&
152+
arrayValue.TryGetValue(out string? arrayText) &&
153+
arrayText is not null)
154+
{
155+
foreach (SensitiveFinding finding in DetectInText($"{path}[{index}]", arrayText))
156+
{
157+
findings.Add(finding);
158+
if (maskInPlace && finding.Action == "masked")
159+
{
160+
array[index] = MaskValue(arrayText, finding.Kind);
161+
}
162+
}
163+
}
164+
}
165+
}
166+
}
167+
}
168+
169+
private static IEnumerable<SensitiveFinding> DetectInText(string path, string text)
170+
{
171+
if (AwsAccessKeyRegex().IsMatch(text))
172+
{
173+
yield return new SensitiveFinding(
174+
SensitiveFindingKind.AwsAccessKey,
175+
path,
176+
Preview(text),
177+
"masked");
178+
}
179+
180+
if (AwsSecretKeyRegex().IsMatch(text) || text.Contains("aws_secret_access_key", StringComparison.OrdinalIgnoreCase))
181+
{
182+
yield return new SensitiveFinding(
183+
SensitiveFindingKind.AwsSecretKey,
184+
path,
185+
Preview(text),
186+
"masked");
187+
}
188+
189+
if (text.Contains("BEGIN PRIVATE KEY", StringComparison.Ordinal) ||
190+
text.Contains("BEGIN RSA PRIVATE KEY", StringComparison.Ordinal))
191+
{
192+
yield return new SensitiveFinding(
193+
SensitiveFindingKind.PrivateKeyBlock,
194+
path,
195+
Preview(text),
196+
"masked");
197+
}
198+
199+
if (JwtRegex().IsMatch(text))
200+
{
201+
yield return new SensitiveFinding(SensitiveFindingKind.Jwt, path, Preview(text), "masked");
202+
}
203+
204+
if (text.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
205+
{
206+
yield return new SensitiveFinding(
207+
SensitiveFindingKind.BearerToken,
208+
path,
209+
Preview(text),
210+
"masked");
211+
}
212+
213+
if (CreditCardRegex().IsMatch(text) && LooksLikeCreditCard(text))
214+
{
215+
yield return new SensitiveFinding(
216+
SensitiveFindingKind.CreditCard,
217+
path,
218+
Preview(text),
219+
"masked");
220+
}
221+
222+
if (EmailRegex().IsMatch(text))
223+
{
224+
yield return new SensitiveFinding(SensitiveFindingKind.Email, path, Preview(text), "masked");
225+
}
226+
227+
if (text.Contains("Connection String", StringComparison.OrdinalIgnoreCase) ||
228+
text.Contains("Password=", StringComparison.OrdinalIgnoreCase) &&
229+
text.Contains(';', StringComparison.Ordinal))
230+
{
231+
yield return new SensitiveFinding(
232+
SensitiveFindingKind.ConnectionString,
233+
path,
234+
Preview(text),
235+
"masked");
236+
}
237+
}
238+
239+
private static bool IsSecretName(string normalized)
240+
{
241+
return normalized is "password" or "passwordhash" or "accesstoken" or "refreshtoken"
242+
or "bearertoken" or "apikeysecret" or "clientsecret" or "privatekey"
243+
or "connectionstring" or "authorization" or "cookie" or "cookies"
244+
or "credential" or "credentials" or "secret" or "apikey";
245+
}
246+
247+
private static string MaskValue(string value, SensitiveFindingKind kind)
248+
{
249+
return kind switch
250+
{
251+
SensitiveFindingKind.Email => MaskEmail(value),
252+
SensitiveFindingKind.CreditCard => "****-****-****-" + DigitsOnly(value)[^4..],
253+
SensitiveFindingKind.AwsAccessKey => value.Length > 8 ? value[..4] + "********" + value[^2..] : "***",
254+
_ => "***REDACTED***"
255+
};
256+
}
257+
258+
private static string MaskEmail(string value)
259+
{
260+
Match match = EmailRegex().Match(value);
261+
if (!match.Success)
262+
{
263+
return "***";
264+
}
265+
266+
string email = match.Value;
267+
int at = email.IndexOf('@', StringComparison.Ordinal);
268+
return at <= 0 ? "***" : $"{email[0]}***{email[at..]}";
269+
}
270+
271+
private static bool LooksLikeCreditCard(string value)
272+
{
273+
string digits = DigitsOnly(value);
274+
if (digits.Length is < 13 or > 19)
275+
{
276+
return false;
277+
}
278+
279+
// Luhn
280+
int sum = 0;
281+
bool alt = false;
282+
for (int i = digits.Length - 1; i >= 0; i--)
283+
{
284+
int n = digits[i] - '0';
285+
if (alt)
286+
{
287+
n *= 2;
288+
if (n > 9)
289+
{
290+
n -= 9;
291+
}
292+
}
293+
294+
sum += n;
295+
alt = !alt;
296+
}
297+
298+
return sum % 10 == 0;
299+
}
300+
301+
private static string DigitsOnly(string value) => string.Concat(value.Where(char.IsDigit));
302+
303+
private static string Normalize(string value) =>
304+
string.Concat(value.Where(char.IsLetterOrDigit)).ToLowerInvariant();
305+
306+
private static string Preview(string? value)
307+
{
308+
if (string.IsNullOrEmpty(value))
309+
{
310+
return string.Empty;
311+
}
312+
313+
return value.Length <= 12 ? "***" : value[..4] + "…";
314+
}
315+
316+
[GeneratedRegex(@"\bAKIA[0-9A-Z]{16}\b", RegexOptions.CultureInvariant)]
317+
private static partial Regex AwsAccessKeyRegex();
318+
319+
[GeneratedRegex(@"\b(?:aws)?_?secret_?(?:access)?_?key\b\s*[:=]\s*\S+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
320+
private static partial Regex AwsSecretKeyRegex();
321+
322+
[GeneratedRegex(@"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b", RegexOptions.CultureInvariant)]
323+
private static partial Regex JwtRegex();
324+
325+
[GeneratedRegex(@"\b(?:\d[ -]*?){13,19}\b", RegexOptions.CultureInvariant)]
326+
private static partial Regex CreditCardRegex();
327+
328+
[GeneratedRegex(@"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
329+
private static partial Regex EmailRegex();
330+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using Bower.Redaction;
2+
3+
namespace Bower.UnitTests;
4+
5+
public sealed class SensitiveDataDetectorTests
6+
{
7+
[Fact]
8+
public void ScanAndRedact_DetectsAwsKeyJwtAndEmail()
9+
{
10+
const string json =
11+
"""
12+
{
13+
"awsKey": "AKIAIOSFODNN7EXAMPLE",
14+
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature",
15+
"owner": "alice@example.test",
16+
"password": "do-not-store"
17+
}
18+
""";
19+
20+
SensitiveDataDetector detector = new();
21+
SensitiveScanResult result = detector.ScanAndRedact(json);
22+
23+
Assert.True(result.Succeeded);
24+
Assert.Contains(result.Findings, item => item.Kind == SensitiveFindingKind.AwsAccessKey);
25+
Assert.Contains(result.Findings, item => item.Kind == SensitiveFindingKind.Jwt);
26+
Assert.Contains(result.Findings, item => item.Kind == SensitiveFindingKind.Email);
27+
Assert.Contains(result.Findings, item => item.Kind == SensitiveFindingKind.GenericSecret);
28+
Assert.DoesNotContain("AKIAIOSFODNN7EXAMPLE", result.RedactedJson);
29+
Assert.DoesNotContain("do-not-store", result.RedactedJson);
30+
Assert.Contains("a***@example.test", result.RedactedJson);
31+
}
32+
33+
[Fact]
34+
public void ScanAndRedact_DetectsValidCreditCard()
35+
{
36+
// Visa test number that passes Luhn
37+
const string json = """{ "pan": "4111111111111111" }""";
38+
SensitiveDataDetector detector = new();
39+
SensitiveScanResult result = detector.ScanAndRedact(json);
40+
41+
Assert.True(result.Succeeded);
42+
Assert.Contains(result.Findings, item => item.Kind == SensitiveFindingKind.CreditCard);
43+
Assert.Contains("****-****-****-1111", result.RedactedJson);
44+
}
45+
}

0 commit comments

Comments
 (0)