Skip to content

Commit 6e56dc9

Browse files
authored
Integrate Secret Scanning from Microsoft.Security.Utilities (#8140)
* Integrate Microsoft.Security.Utilities into the test-proxy, preventing pushes that contain detected secrets.
1 parent f02480d commit 6e56dc9

8 files changed

Lines changed: 235 additions & 14 deletions

File tree

tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/IntegrationTests/GitStoreIntegrationPushTests.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Text.Json;
66
using System.Threading.Tasks;
77
using Azure.Sdk.Tools.TestProxy.Common;
8+
using Azure.Sdk.Tools.TestProxy.Console;
89
using Azure.Sdk.Tools.TestProxy.Store;
910
using Microsoft.Extensions.Logging;
1011
using Xunit;
@@ -581,5 +582,79 @@ public async Task LargePushPerformance(int numberOfFiles, double fileSize)
581582
TestHelpers.CleanupIntegrationTestTag(updatedAssets);
582583
}
583584
}
585+
586+
/// <summary>
587+
/// 1. Restore from empty tag
588+
/// 2. Add/Delete/Update files which will include a fake secret
589+
/// 3. Attempt to push
590+
/// 4. Assert that the expected exception is thrown, preventing a secret being pushed upstream
591+
/// </summary>
592+
/// <param name="inputJson"></param>
593+
/// <returns></returns>
594+
[EnvironmentConditionalSkipTheory]
595+
[InlineData(
596+
@"{
597+
""AssetsRepo"": ""Azure/azure-sdk-assets-integration"",
598+
""AssetsRepoPrefixPath"": ""pull/scenarios"",
599+
""AssetsRepoId"": """",
600+
""TagPrefix"": ""language/tables"",
601+
""Tag"": """"
602+
}")]
603+
[Trait("Category", "Integration")]
604+
public async Task SecretProtectionPreventsPush(string inputJson)
605+
{
606+
var folderStructure = new string[]
607+
{
608+
GitStoretests.AssetsJson
609+
};
610+
Assets assets = JsonSerializer.Deserialize<Assets>(inputJson);
611+
var testFolder = TestHelpers.DescribeTestFolder(assets, folderStructure, isPushTest: true);
612+
try
613+
{
614+
ConsoleWrapper consoleWrapper = new ConsoleWrapper();
615+
GitStore store = new GitStore(consoleWrapper);
616+
var recordingHandler = new RecordingHandler(testFolder, store);
617+
618+
var jsonFileLocation = Path.Join(testFolder, GitStoretests.AssetsJson);
619+
620+
var parsedConfiguration = await store.ParseConfigurationFile(jsonFileLocation);
621+
await _defaultStore.Restore(jsonFileLocation);
622+
623+
// Calling Path.GetFullPath of the Path.Combine will ensure any directory separators are normalized for
624+
// the OS the test is running on. The reason being is that AssetsRepoPrefixPath, if there's a separator,
625+
// will be a forward one as expected by git but on Windows this won't result in a usable path.
626+
string localFilePath = Path.GetFullPath(Path.Combine(parsedConfiguration.AssetsRepoLocation, parsedConfiguration.AssetsRepoPrefixPath));
627+
628+
// generate a couple strings that LOOKs like secrets to the secret scanner.
629+
var secretType1 = TestHelpers.GenerateString(3) + "8Q~" + TestHelpers.GenerateString(34);
630+
631+
// place an entirely new file with the secret
632+
TestHelpers.CreateOrUpdateFileWithContent(localFilePath, "secret_type_1.txt", secretType1);
633+
634+
// modify an existing file with the secret
635+
TestHelpers.CreateOrUpdateFileWithContent(localFilePath, "file2.txt", secretType1);
636+
637+
// delete a file to ensure that we don't attempt to scan a file that no longer exists
638+
File.Delete(Path.Combine(localFilePath, "file5.txt"));
639+
640+
// Use the built in secretscanner
641+
await store.Push(jsonFileLocation);
642+
643+
// no changes should be committed
644+
var pendingChanges = store.DetectPendingChanges(parsedConfiguration);
645+
Assert.Equal(2, pendingChanges.Count());
646+
647+
// now double check the actual scan results to ensure they are where we expect
648+
var detectedSecrets = store.SecretScanner.DiscoverSecrets(parsedConfiguration.AssetsRepoLocation, pendingChanges);
649+
650+
Assert.Equal(2, detectedSecrets.Count);
651+
Assert.Equal("SEC101/156", detectedSecrets[0].Item2.Id);
652+
Assert.Equal("SEC101/156", detectedSecrets[1].Item2.Id);
653+
}
654+
finally
655+
{
656+
DirectoryHelper.DeleteGitDirectory(testFolder);
657+
}
658+
}
584659
}
585660
}

tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/TestHelpers.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using System.Linq;
1212
using Xunit;
1313
using System.Threading.Tasks;
14+
using System.Security.Cryptography;
1415

1516
namespace Azure.Sdk.Tools.TestProxy.Tests
1617
{
@@ -347,6 +348,18 @@ public static void CreateFileWithInitialVersion(string testFolder, string fileNa
347348
File.WriteAllText(fullFileName, "1");
348349
}
349350

351+
/// <summary>
352+
/// Create a new file with custom text
353+
/// </summary>
354+
/// <param name="testFolder">The temporary test folder created by TestHelpers.DescribeTestFolder</param>
355+
/// <param name="fileName">The file to be created</param>
356+
public static void CreateOrUpdateFileWithContent(string testFolder, string fileName, string textContent)
357+
{
358+
string fullFileName = Path.Combine(testFolder, fileName);
359+
360+
File.WriteAllText(fullFileName, textContent);
361+
}
362+
350363
/// <summary>
351364
/// This function is used to confirm that the .breadcrumb file under the assets store contains the appropriate
352365
/// information.
@@ -517,6 +530,23 @@ public static bool CheckExistenceOfTag(Assets assets, string workingDirectory)
517530
return result.StdOut.Trim().Length > 0;
518531
}
519532

533+
public static string GenerateString(int count)
534+
{
535+
char[] alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".ToArray();
536+
537+
StringBuilder builder = new StringBuilder();
538+
539+
for (int i = 0; i < count; i++)
540+
{
541+
var bytes = RandomNumberGenerator.GetBytes(1);
542+
int index = bytes[0] % alphabet.Length;
543+
char ch = alphabet[index];
544+
_ = builder.Append(ch);
545+
}
546+
547+
return builder.ToString();
548+
}
549+
520550
public static List<T> EnumerateArray<T>(JsonElement element)
521551
{
522552
List<T> values = new List<T>();

tools/test-proxy/Azure.Sdk.Tools.TestProxy/Azure.Sdk.Tools.TestProxy.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
</ItemGroup>
2323

2424
<ItemGroup>
25+
<PackageReference Include="Microsoft.Security.Utilities.Core" Version="1.4.14" />
2526
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
2627
</ItemGroup>
2728
</Project>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System;
2+
using System.Collections.Concurrent;
3+
using System.Collections.Generic;
4+
using System.IO;
5+
using System.Linq;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Azure.Sdk.Tools.TestProxy.Console;
9+
using Microsoft.Build.Tasks;
10+
using Microsoft.Security.Utilities;
11+
12+
namespace Azure.Sdk.Tools.TestProxy.Common
13+
{
14+
public class SecretScanner
15+
{
16+
public SecretMasker SecretMasker = new SecretMasker(
17+
WellKnownRegexPatterns.HighConfidenceMicrosoftSecurityModels.Concat(WellKnownRegexPatterns.LowConfidencePotentialSecurityKeys),
18+
generateCorrelatingIds: true);
19+
20+
private IConsoleWrapper Console;
21+
22+
public SecretScanner(IConsoleWrapper consoleWrapper)
23+
{
24+
Console = consoleWrapper;
25+
}
26+
27+
public List<Tuple<string, Detection>> DiscoverSecrets(string assetRepoRoot, IEnumerable<string> relativePaths)
28+
{
29+
var detectedSecrets = new ConcurrentBag<Tuple<string, Detection>>();
30+
var total = relativePaths.Count();
31+
var seen = 0;
32+
Console.WriteLine(string.Empty);
33+
34+
var options = new ParallelOptions
35+
{
36+
MaxDegreeOfParallelism = Environment.ProcessorCount
37+
};
38+
39+
Parallel.ForEach(relativePaths, options, (filePath) =>
40+
{
41+
var content = File.ReadAllText(Path.Combine(assetRepoRoot, filePath));
42+
var fileDetections = DetectSecrets(content);
43+
44+
if (fileDetections != null && fileDetections.Count > 0)
45+
{
46+
foreach (Detection detection in fileDetections)
47+
{
48+
detectedSecrets.Add(Tuple.Create(filePath, detection));
49+
}
50+
}
51+
52+
Interlocked.Increment(ref seen);
53+
54+
Console.Write($"\r\u001b[2KScanned {seen}/{total}.");
55+
});
56+
57+
Console.WriteLine(string.Empty);
58+
59+
return detectedSecrets.ToList();
60+
}
61+
62+
private async Task<string> ReadFile(string filePath)
63+
{
64+
using (StreamReader reader = new StreamReader(filePath))
65+
{
66+
return await reader.ReadToEndAsync();
67+
}
68+
}
69+
70+
private ICollection<Detection> DetectSecrets(string stringContent)
71+
{
72+
return SecretMasker.DetectSecrets(stringContent);
73+
}
74+
75+
}
76+
}

tools/test-proxy/Azure.Sdk.Tools.TestProxy/Console/ConsoleWrapper.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-

2-
using System;
1+
32

43
namespace Azure.Sdk.Tools.TestProxy.Console
54
{

tools/test-proxy/Azure.Sdk.Tools.TestProxy/Console/ConsoleWrapperTester.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System;
21

32
namespace Azure.Sdk.Tools.TestProxy.Console
43
{
@@ -28,14 +27,17 @@ public void SetReadLineResponse(string readLineResponse)
2827
{
2928
_readLineResponse = readLineResponse;
3029
}
30+
3131
public void Write(string message)
3232
{
3333
System.Console.Write(message);
3434
}
35+
3536
public void WriteLine(string message)
3637
{
3738
System.Console.WriteLine(message);
3839
}
40+
3941
public string ReadLine()
4042
{
4143
System.Console.WriteLine($"ReadLine response for test: '{_readLineResponse}'");

tools/test-proxy/Azure.Sdk.Tools.TestProxy/Console/IConsoleWrapper.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
namespace Azure.Sdk.Tools.TestProxy.Console
1+
namespace Azure.Sdk.Tools.TestProxy.Console
22
{
33
/// <summary>
44
/// IConsoleWrapper is just an interface around Console functions. This is necessary for testing

tools/test-proxy/Azure.Sdk.Tools.TestProxy/Store/GitStore.cs

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,16 @@
33
using System;
44
using System.Text.Json;
55
using System.Threading.Tasks;
6-
using System.Diagnostics;
76
using System.Net.Http;
87
using System.Net.Http.Headers;
9-
using System.Text;
108
using System.Linq;
119
using Azure.Sdk.Tools.TestProxy.Common.Exceptions;
1210
using Azure.Sdk.Tools.TestProxy.Common;
1311
using Azure.Sdk.Tools.TestProxy.Console;
1412
using System.Collections.Concurrent;
15-
using System.Reflection.Metadata;
1613
using System.Text.RegularExpressions;
1714
using Azure.Sdk.tools.TestProxy.Common;
15+
using Microsoft.Security.Utilities;
1816

1917
namespace Azure.Sdk.Tools.TestProxy.Store
2018
{
@@ -41,6 +39,7 @@ public class GitStore : IAssetsStore
4139
public static readonly string GIT_COMMIT_OWNER_ENV_VAR = "GIT_COMMIT_OWNER";
4240
public static readonly string GIT_COMMIT_EMAIL_ENV_VAR = "GIT_COMMIT_EMAIL";
4341
private bool LocalCacheRefreshed = false;
42+
public SecretScanner SecretScanner;
4443
public readonly object LocalCacheLock = new object();
4544

4645
public GitStoreBreadcrumb BreadCrumb = new GitStoreBreadcrumb();
@@ -66,15 +65,13 @@ public class GitStore : IAssetsStore
6665
public GitStore()
6766
{
6867
_consoleWrapper = new ConsoleWrapper();
68+
SecretScanner = new SecretScanner(_consoleWrapper);
6969
}
7070

7171
public GitStore(IConsoleWrapper consoleWrapper)
7272
{
7373
_consoleWrapper = consoleWrapper;
74-
}
75-
76-
public GitStore(GitProcessHandler processHandler) {
77-
GitHandler = processHandler;
74+
SecretScanner = new SecretScanner(consoleWrapper);
7875
}
7976

8077
#region push, reset, restore, and other asset repo implementations
@@ -95,6 +92,31 @@ public async Task<NormalizedString> GetPath(string pathToAssetsJson)
9592
return new NormalizedString(config.AssetsRepoLocation);
9693
}
9794

95+
/// <summary>
96+
/// Scans the changed files, checking for possible secrets. Returns true if secrets are discovered.
97+
/// </summary>
98+
/// <param name="assetsConfiguration"></param>
99+
/// <param name="pendingChanges"></param>
100+
/// <returns></returns>
101+
public bool CheckForSecrets(GitAssetsConfiguration assetsConfiguration, string[] pendingChanges)
102+
{
103+
_consoleWrapper.WriteLine($"Detected new recordings. Prior to pushing to destination repo, test-proxy will scan {pendingChanges.Length} files.");
104+
var detectedSecrets = SecretScanner.DiscoverSecrets(assetsConfiguration.AssetsRepoLocation, pendingChanges);
105+
106+
if (detectedSecrets.Count > 0)
107+
{
108+
_consoleWrapper.WriteLine("At least one secret was detected in the pushed code. Please register a sanitizer, re-record, and attempt pushing again. Detailed errors follow: ");
109+
foreach (var detection in detectedSecrets)
110+
{
111+
_consoleWrapper.WriteLine($"{detection.Item1}");
112+
_consoleWrapper.WriteLine($"\t{detection.Item2.Id}: {detection.Item2.Name}");
113+
_consoleWrapper.WriteLine($"\tStart: {detection.Item2.Start}, End: {detection.Item2.End}.\n");
114+
}
115+
}
116+
117+
return detectedSecrets.Count > 0;
118+
}
119+
98120
/// <summary>
99121
/// Pushes a set of changed files to the assets repo. Honors configuration of assets.json passed into it.
100122
/// </summary>
@@ -121,6 +143,12 @@ public async Task Push(string pathToAssetsJson) {
121143

122144
if (pendingChanges.Length > 0)
123145
{
146+
if (CheckForSecrets(config, pendingChanges))
147+
{
148+
Environment.ExitCode = -1;
149+
return;
150+
}
151+
124152
try
125153
{
126154
string branchGuid = Guid.NewGuid().ToString().Substring(0, 8);
@@ -347,9 +375,19 @@ public string[] DetectPendingChanges(GitAssetsConfiguration config)
347375

348376
if (!string.IsNullOrWhiteSpace(diffResult.StdOut))
349377
{
350-
// Normally, we'd use Environment.NewLine here but this doesn't work on Windows since its NewLine is \r\n and
351-
// Git's NewLine is just \n
352-
var individualResults = diffResult.StdOut.Split("\n").Select(x => x.Trim()).ToArray();
378+
/*
379+
* Normally, we'd use Environment.NewLine here but this doesn't work on Windows since its NewLine is \r\n and Git's NewLine is just \n
380+
*
381+
* The output from git status porcelain will include two possible additional values
382+
* " ?? path/to/file" -> File that is new
383+
* " M path/to/file" -> File that is modified
384+
* " D path/to/file" -> File that is deleted
385+
*/
386+
var individualResults = diffResult.StdOut.Split("\n")
387+
// strim the leading space, the characters for ADDED or MODIFIED, and the space after them
388+
.Select(x => x.Trim().TrimStart('?', 'M').Trim())
389+
// exclude empty paths or paths that have been DELETED
390+
.Where(x => !string.IsNullOrWhiteSpace(x) && !x.StartsWith("D")).ToArray();
353391
return individualResults;
354392
}
355393

0 commit comments

Comments
 (0)