Skip to content

Commit ea409ef

Browse files
authored
Send secret masking telemetry if opted in to it (#5189)
If using the new OSS secret masker and an additional opt-in knob is turned on, then send telemetry about secrets that were masked. An overall 'SecretMasker' event is sent with statistics such as total detections and elapsed time. Additional 'SecretMaskerDetections' events are sent mapping C3ID to pattern monikers. These are batched to keep event sizes capped and the total number of events are also capped. This also includes an update to the Microsoft.Security.Utilities.Core dependency from v1.18 to v1.19. Release notes: https://github.com/microsoft/security-utilities/blob/release/v1.19.0/docs/ReleaseHistory.md
1 parent b57ff78 commit ea409ef

File tree

9 files changed

+440
-70
lines changed

9 files changed

+440
-70
lines changed

src/Agent.Sdk/Agent.Sdk.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
</PropertyGroup>
88

99
<ItemGroup>
10-
<PackageReference Include="Microsoft.Security.Utilities.Core" Version="1.18.0" />
10+
<PackageReference Include="Microsoft.Security.Utilities.Core" Version="1.19.0" />
1111
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
1212
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="6.0.0-preview.5.21301.5" />
1313
<PackageReference Include="System.Management" Version="4.7.0" />

src/Agent.Sdk/Knob/AgentKnobs.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,13 @@ public class AgentKnobs
681681
new EnvironmentKnobSource("AZP_ENABLE_NEW_MASKER_AND_REGEXES"),
682682
new BuiltInDefaultKnobSource("false"));
683683

684+
public static readonly Knob SendSecretMaskerTelemetry = new Knob(
685+
nameof(SendSecretMaskerTelemetry),
686+
"If true, the agent will send telemetry about secret masking",
687+
new RuntimeKnobSource("AZP_SEND_SECRET_MASKER_TELEMETRY"),
688+
new EnvironmentKnobSource("AZP_SEND_SECRET_MASKER_TELEMETRY"),
689+
new BuiltInDefaultKnobSource("false"));
690+
684691
public static readonly Knob AddDockerInitOption = new Knob(
685692
nameof(AddDockerInitOption),
686693
"If true, the agent will create docker container with the --init option.",

src/Agent.Sdk/SecretMasking/ILoggedSecretMasker.cs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22
// Licensed under the MIT License.
33

44
using System;
5+
using System.Collections.Generic;
56

67
namespace Agent.Sdk.SecretMasking
78
{
89
/// <summary>
9-
/// Extended ISecretMasker interface that adds support for logging the origin of
10-
/// regexes, encoders and literal secret values.
10+
/// An action that publishes the given data corresonding to the given
11+
/// feature to a telemetry channel.
12+
/// </summary>
13+
public delegate void PublishSecretMaskerTelemetryAction(string feature, Dictionary<string, string> data);
14+
15+
/// <summary>
16+
/// Extended ISecretMasker interface that adds support for telemetry and
17+
/// logging the origin of regexes, encoders and literal secret values.
1118
/// </summary>
1219
public interface ILoggedSecretMasker : IDisposable
1320
{
@@ -19,5 +26,34 @@ public interface ILoggedSecretMasker : IDisposable
1926
string MaskSecrets(string input);
2027
void RemoveShortSecretsFromDictionary();
2128
void SetTrace(ITraceWriter trace);
29+
30+
/// <summary>
31+
/// Begin collecting data for secret masking telemetry.
32+
/// </summary>
33+
/// <remarks>
34+
/// This is a no-op if <see cref="LegacySecretMasker"/> is being used,
35+
/// only <see cref="OssSecretMasker"/> supports telemetry. Also, the
36+
/// agent will only call this if a feature flag that opts in to secret
37+
/// masking telemetry is enabled..
38+
/// </remarks>
39+
/// <param name="maxUniqueCorrelatingIds">
40+
/// The maximum number of unique correlating IDs to collect.
41+
/// </param>
42+
void StartTelemetry(int maxUniqueCorrelatingIds);
43+
44+
/// <summary>
45+
/// Stop collecting data for secret masking telemetry and publish the
46+
/// telemetry events.
47+
/// </summary>
48+
/// <remarks>
49+
/// This is a no-op if <see cref="LegacySecretMasker"/> is being used,
50+
/// only <see cref="OssSecretMasker"/> supports telemetry.
51+
/// <param name="maxCorrelatingIdsPerEvent">
52+
/// The maximum number of correlating IDs to report in a single
53+
/// telemetry event.
54+
/// <param name="publishAction">
55+
/// Callback to publish the telemetry data.
56+
/// </param>
57+
void StopAndPublishTelemetry(int maxCorrelatingIdsPerEvent, PublishSecretMaskerTelemetryAction publishAction);
2258
}
2359
}

src/Agent.Sdk/SecretMasking/LoggedSecretMasker.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
// Copyright (c) Microsoft Corporation.
32
// Licensed under the MIT License.
43

@@ -143,6 +142,16 @@ public void Dispose()
143142
GC.SuppressFinalize(this);
144143
}
145144

145+
public void StartTelemetry(int maxDetections)
146+
{
147+
(_secretMasker as OssSecretMasker)?.StartTelemetry(maxDetections);
148+
}
149+
150+
public void StopAndPublishTelemetry(int maxDetectionsPerEvent, PublishSecretMaskerTelemetryAction publishAction)
151+
{
152+
(_secretMasker as OssSecretMasker)?.StopAndPublishTelemetry(publishAction, maxDetectionsPerEvent);
153+
}
154+
146155
protected virtual void Dispose(bool disposing)
147156
{
148157
if (disposing)
Lines changed: 153 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33
using System;
4+
using System.Collections.Concurrent;
45
using System.Collections.Generic;
6+
using System.Globalization;
57
using System.Text.RegularExpressions;
8+
using System.Threading;
9+
610
using Microsoft.Security.Utilities;
711

812
namespace Agent.Sdk.SecretMasking;
913

1014
public sealed class OssSecretMasker : IRawSecretMasker
1115
{
1216
private SecretMasker _secretMasker;
17+
private Telemetry _telemetry;
1318

14-
public OssSecretMasker() : this(Array.Empty<RegexPattern>())
15-
{
16-
}
17-
18-
public OssSecretMasker(IEnumerable<RegexPattern> patterns)
19+
public OssSecretMasker(IEnumerable<RegexPattern> patterns = null)
1920
{
20-
_secretMasker = new SecretMasker(patterns, generateCorrelatingIds: true);
21-
_secretMasker.DefaultRegexRedactionToken = "***";
21+
_secretMasker = new SecretMasker(patterns,
22+
generateCorrelatingIds: true,
23+
defaultRegexRedactionToken: "***");
2224
}
2325

24-
2526
/// <summary>
2627
/// This property allows to set the minimum length of a secret for masking
2728
/// </summary>
@@ -31,9 +32,6 @@ public int MinSecretLength
3132
set => _secretMasker.MinimumSecretLength = value;
3233
}
3334

34-
/// <summary>
35-
/// This implementation assumes no more than one thread is adding regexes, values, or encoders at any given time.
36-
/// </summary>
3735
public void AddRegex(string pattern)
3836
{
3937
// NOTE: This code path is used for regexes sent to the agent via
@@ -52,95 +50,189 @@ public void AddRegex(string pattern)
5250
_secretMasker.AddRegex(regexPattern);
5351
}
5452

55-
/// <summary>
56-
/// This implementation assumes no more than one thread is adding regexes, values, or encoders at any given time.
57-
/// </summary>
5853
public void AddValue(string test)
5954
{
6055
_secretMasker.AddValue(test);
6156
}
6257

63-
/// <summary>
64-
/// This implementation assumes no more than one thread is adding regexes, values, or encoders at any given time.
65-
/// </summary>
6658
public void AddValueEncoder(Func<string, string> encoder)
6759
{
68-
_secretMasker.AddLiteralEncoder(x => encoder(x));
60+
_secretMasker.AddLiteralEncoder(x => encoder(x));
6961
}
7062

7163
public void Dispose()
7264
{
7365
_secretMasker?.Dispose();
7466
_secretMasker = null;
67+
_telemetry = null;
7568
}
7669

7770
public string MaskSecrets(string input)
7871
{
79-
return _secretMasker.MaskSecrets(input);
72+
_secretMasker.SyncObject.EnterReadLock();
73+
try
74+
{
75+
_telemetry?.ProcessInput(input);
76+
return _secretMasker.MaskSecrets(input, _telemetry?.ProcessDetection);
77+
}
78+
finally
79+
{
80+
_secretMasker.SyncObject.ExitReadLock();
81+
}
8082
}
8183

82-
/// <summary>
83-
/// Removes secrets from the dictionary shorter than the MinSecretLength property.
84-
/// This implementation assumes no more than one thread is adding regexes, values, or encoders at any given time.
85-
/// </summary>
86-
public void RemoveShortSecretsFromDictionary()
84+
public void StartTelemetry(int maxUniqueCorrelatingIds)
8785
{
88-
var filteredValueSecrets = new HashSet<SecretLiteral>();
89-
var filteredRegexSecrets = new HashSet<RegexPattern>();
90-
86+
_secretMasker.SyncObject.EnterWriteLock();
9187
try
9288
{
93-
_secretMasker.SyncObject.EnterReadLock();
89+
_telemetry ??= new Telemetry(maxUniqueCorrelatingIds);
90+
}
91+
finally
92+
{
93+
_secretMasker.SyncObject.ExitWriteLock();
94+
}
95+
}
9496

95-
foreach (var secret in _secretMasker.EncodedSecretLiterals)
96-
{
97-
if (secret.Value.Length < MinSecretLength)
98-
{
99-
filteredValueSecrets.Add(secret);
100-
}
101-
}
97+
public void StopAndPublishTelemetry(PublishSecretMaskerTelemetryAction publishAction, int maxCorrelatingIdsPerEvent)
98+
{
99+
Telemetry telemetry;
102100

103-
foreach (var secret in _secretMasker.RegexPatterns)
104-
{
105-
if (secret.Pattern.Length < MinSecretLength)
106-
{
107-
filteredRegexSecrets.Add(secret);
108-
}
109-
}
101+
_secretMasker.SyncObject.EnterWriteLock();
102+
try
103+
{
104+
telemetry = _telemetry;
105+
_telemetry = null;
110106
}
111107
finally
112108
{
113-
if (_secretMasker.SyncObject.IsReadLockHeld)
114-
{
115-
_secretMasker.SyncObject.ExitReadLock();
116-
}
109+
_secretMasker.SyncObject.ExitWriteLock();
117110
}
118111

119-
try
112+
telemetry?.Publish(publishAction, _secretMasker.ElapsedMaskingTime, maxCorrelatingIdsPerEvent);
113+
}
114+
115+
private sealed class Telemetry
116+
{
117+
// NOTE: Telemetry does not fit into the reader-writer lock model of the
118+
// SecretMasker API because we *write* telemetry during *read*
119+
// operations. We therefore use separate interlocked operations and a
120+
// concurrent dictionary when writing to telemetry.
121+
122+
// Key=CrossCompanyCorrelatingId (C3ID), Value=Rule Moniker C3ID is a
123+
// non-reversible seeded hash and only available when detection is made
124+
// by a high-confidence rule that matches secrets with high entropy.
125+
private readonly ConcurrentDictionary<string, string> _correlationData;
126+
private readonly int _maxUniqueCorrelatingIds;
127+
private long _charsScanned;
128+
private long _stringsScanned;
129+
private long _totalDetections;
130+
131+
public Telemetry(int maxDetections)
120132
{
121-
_secretMasker.SyncObject.EnterWriteLock();
133+
_correlationData = new ConcurrentDictionary<string, string>();
134+
_maxUniqueCorrelatingIds = maxDetections;
135+
ProcessDetection = ProcessDetectionImplementation;
136+
}
122137

123-
foreach (var secret in filteredValueSecrets)
124-
{
125-
_secretMasker.EncodedSecretLiterals.Remove(secret);
126-
}
138+
public void ProcessInput(string input)
139+
{
140+
Interlocked.Add(ref _charsScanned, input.Length);
141+
Interlocked.Increment(ref _stringsScanned);
142+
}
127143

128-
foreach (var secret in filteredRegexSecrets)
129-
{
130-
_secretMasker.RegexPatterns.Remove(secret);
131-
}
144+
public Action<Detection> ProcessDetection { get; }
132145

133-
foreach (var secret in filteredValueSecrets)
146+
private void ProcessDetectionImplementation(Detection detection)
147+
{
148+
Interlocked.Increment(ref _totalDetections);
149+
150+
// NOTE: We cannot prevent the concurrent dictionary from exceeding
151+
// the maximum detection count when multiple threads add detections
152+
// in parallel. The condition here is therefore a best effort to
153+
// constrain the memory consumed by excess detections that will not
154+
// be published. Furthermore, it is deliberate that we use <=
155+
// instead of < here as it allows us to detect the case where the
156+
// maximum number of events have been exceeded without adding any
157+
// additional state.
158+
if (_correlationData.Count <= _maxUniqueCorrelatingIds &&
159+
detection.CrossCompanyCorrelatingId != null)
134160
{
135-
_secretMasker.ExplicitlyAddedSecretLiterals.Remove(secret);
161+
_correlationData.TryAdd(detection.CrossCompanyCorrelatingId, detection.Moniker);
136162
}
137163
}
138-
finally
164+
165+
public void Publish(PublishSecretMaskerTelemetryAction publishAction, TimeSpan elapsedMaskingTime, int maxCorrelatingIdsPerEvent)
139166
{
140-
if (_secretMasker.SyncObject.IsWriteLockHeld)
167+
Dictionary<string, string> correlationData = null;
168+
int uniqueCorrelatingIds = 0;
169+
bool correlationDataIsIncomplete = false;
170+
171+
// Publish 'SecretMaskerCorrelation' events mapping unique C3IDs to
172+
// rule moniker. No more than 'maxCorrelatingIdsPerEvent' are
173+
// published in a single event.
174+
foreach (var pair in _correlationData)
141175
{
142-
_secretMasker.SyncObject.ExitWriteLock();
176+
if (uniqueCorrelatingIds >= _maxUniqueCorrelatingIds)
177+
{
178+
correlationDataIsIncomplete = true;
179+
break;
180+
}
181+
182+
correlationData ??= new Dictionary<string, string>(maxCorrelatingIdsPerEvent);
183+
correlationData.Add(pair.Key, pair.Value);
184+
uniqueCorrelatingIds++;
185+
186+
if (correlationData.Count >= maxCorrelatingIdsPerEvent)
187+
{
188+
publishAction("SecretMaskerCorrelation", correlationData);
189+
correlationData = null;
190+
}
143191
}
192+
193+
if (correlationData != null)
194+
{
195+
publishAction("SecretMaskerCorrelation", correlationData);
196+
correlationData = null;
197+
}
198+
199+
// Send overall information in a 'SecretMasker' event.
200+
var overallData = new Dictionary<string, string> {
201+
// The version of Microsoft.Security.Utilities.Core used.
202+
{ "Version", SecretMasker.Version.ToString() },
203+
204+
// The total number number of characters scanned by the secret masker.
205+
{ "CharsScanned", _charsScanned.ToString(CultureInfo.InvariantCulture) },
206+
207+
// The total number of strings scanned by the secret masker.
208+
{ "StringsScanned", _stringsScanned.ToString(CultureInfo.InvariantCulture) },
209+
210+
// The total number of detections made by the secret masker.
211+
// This includes duplicate detections and detections without
212+
// correlating IDs such as those made by literal values.
213+
{ "TotalDetections", _totalDetections.ToString(CultureInfo.InvariantCulture) },
214+
215+
// The total amount of time spent masking secrets.
216+
{ "ElapsedMaskingTimeInMilliseconds", elapsedMaskingTime.TotalMilliseconds.ToString(CultureInfo.InvariantCulture) },
217+
218+
// Whether the 'maxUniqueCorrelatingIds' limit was exceeded and
219+
// therefore the 'SecretMaskerDetectionCorrelation' events does
220+
// not contain all unique correlating IDs detected.
221+
{ "CorrelationDataIsIncomplete", correlationDataIsIncomplete.ToString(CultureInfo.InvariantCulture) },
222+
223+
// The total number of unique correlating IDs reported in
224+
// 'SecretMaskerCorrelation' events.
225+
//
226+
// NOTE: This may be less than the total number of unique
227+
// correlating IDs if the maximum was exceeded. See above.
228+
{ "UniqueCorrelatingIds", uniqueCorrelatingIds.ToString(CultureInfo.InvariantCulture) },
229+
};
230+
231+
publishAction("SecretMasker", overallData);
144232
}
145233
}
234+
235+
// This is a no-op for the OSS SecretMasker because it respects
236+
// MinimumSecretLength immediately without requiring an extra API call.
237+
void IRawSecretMasker.RemoveShortSecretsFromDictionary() { }
146238
}

0 commit comments

Comments
 (0)