Merge pull request #6324 from duplicati/feature/improve-secret-provider-matching
Improve secret matching
This commit is contained in:
@@ -168,7 +168,7 @@ public static class SecretProviderHelper
|
||||
{
|
||||
// Unwrap ${} to support ${name is long}
|
||||
var suffix = string.Empty;
|
||||
var matcher = @"(\w|/)";
|
||||
var matcher = @"(\w|_|-|/)";
|
||||
if (matchpattern.EndsWith("{}") || matchpattern.EndsWith("()") || matchpattern.EndsWith("[]"))
|
||||
{
|
||||
suffix = matchpattern[^1..];
|
||||
@@ -179,10 +179,13 @@ public static class SecretProviderHelper
|
||||
// For the values, they could be urls, so we need to look inside the strings
|
||||
var pattern = new Regex(@$"{Regex.Escape(matchpattern)}(?<key>{matcher}+){Regex.Escape(suffix)}", RegexOptions.ExplicitCapture);
|
||||
|
||||
// Collect all partial matches, so we can report them later
|
||||
var partialMatches = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// When we get the secrets, replace these values
|
||||
var optionsMap = options
|
||||
.Where(x => !x.Key.StartsWith("secret-provider", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(x => (x.Key, Secret: GetKey(x.Value, pattern)))
|
||||
.Select(x => (x.Key, Secret: GetKeyEx(x.Value, pattern, matchpattern, partialMatches)))
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Secret))
|
||||
.GroupBy(x => x.Secret!)
|
||||
.ToDictionary(x => x.Key, x => x.Select(y => y.Key).ToArray());
|
||||
@@ -192,7 +195,7 @@ public static class SecretProviderHelper
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.First?.Query))
|
||||
.Select(x => (Source: x.Second, Params: HttpUtility.ParseQueryString(x.First!.Query)))
|
||||
.SelectMany(x => x.Params.AllKeys.Select(k => (Source: x.Source, Key: k, Value: x.Params[k])))
|
||||
.Select(x => (x.Source, x.Key, Secret: GetKey(x.Value, pattern)))
|
||||
.Select(x => (x.Source, x.Key, Secret: GetKeyEx(x.Value, pattern, matchpattern, partialMatches)))
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Secret))
|
||||
.GroupBy(x => x.Secret!)
|
||||
.ToDictionary(x => x.Key, x => x.Select(y => (y.Source, y.Key)).ToArray());
|
||||
@@ -203,7 +206,7 @@ public static class SecretProviderHelper
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Value.Query))
|
||||
.Select(x => (Source: x.Source, Params: x.Value.QueryParameters))
|
||||
.SelectMany(x => x.Params.AllKeys.Select(k => (Source: x.Source, Key: k, Value: x.Params[k])))
|
||||
.Select(x => (x.Source, x.Key, Secret: GetKey(x.Value, pattern)))
|
||||
.Select(x => (x.Source, x.Key, Secret: GetKeyEx(x.Value, pattern, matchpattern, partialMatches)))
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Secret))
|
||||
.GroupBy(x => x.Secret!)
|
||||
.ToDictionary(x => x.Key, x => x.Select(y => (y.Source, y.Key)).ToArray());
|
||||
@@ -214,6 +217,10 @@ public static class SecretProviderHelper
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
// If there are partial matches, log them
|
||||
if (partialMatches.Count > 0)
|
||||
Log.WriteWarningMessage(LOGTAG, "SecretProviderPartialMatches", null, "Found {0} partial secret matches where the value starts with the pattern {1}, value transformation not applied to those", partialMatches.Count, matchpattern);
|
||||
|
||||
if (secrets.Length == 0)
|
||||
return;
|
||||
|
||||
@@ -257,6 +264,30 @@ public static class SecretProviderHelper
|
||||
return;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key from a value using the pattern, and also collects partial matches
|
||||
/// </summary>
|
||||
/// <param name="value">The value to get the key from</param>
|
||||
/// <param name="pattern">The pattern to use</param>
|
||||
/// <param name="prefix">The prefix to look for</param>
|
||||
/// <param name="partialMatches">A set to collect partial matches</param>
|
||||
/// <returns>>The key or null if not found</returns>
|
||||
private static string? GetKeyEx(string? value, Regex pattern, string prefix, HashSet<string> partialMatches)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var match = GetKey(value, pattern);
|
||||
if (match != null)
|
||||
return match;
|
||||
|
||||
// If the value starts with the prefix, but does not match the pattern, it is a partial match
|
||||
if (value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
partialMatches.Add(value);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key from a value using the pattern
|
||||
/// </summary>
|
||||
|
||||
@@ -24,6 +24,7 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Logging;
|
||||
using Duplicati.Library.Main;
|
||||
using Duplicati.Library.Utility;
|
||||
using NUnit.Framework;
|
||||
@@ -71,7 +72,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
|
||||
[Test]
|
||||
[Category("SecretHelper")]
|
||||
public void ReplaceSecretsWithDefaultSettings()
|
||||
public async Task ReplaceSecretsWithDefaultSettings()
|
||||
{
|
||||
var secretProvider = new MockedSecretProvider();
|
||||
secretProvider.Secrets["key1"] = "secret1";
|
||||
@@ -92,7 +93,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
new Library.Utility.Uri("test://host?pass=$key2&user=$key1&other=123")
|
||||
};
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual("value1", settings["key1"]);
|
||||
Assert.AreEqual("secret2", settings["key2"]);
|
||||
@@ -103,7 +104,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
|
||||
[Test]
|
||||
[Category("SecretHelper")]
|
||||
public void ReplaceSecretsWithSlashKeys()
|
||||
public async Task ReplaceSecretsWithSlashKeys()
|
||||
{
|
||||
var secretProvider = new MockedSecretProvider();
|
||||
secretProvider.Secrets["key1"] = "secret1";
|
||||
@@ -124,7 +125,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
new Library.Utility.Uri("test://host?pass=$key/with/slash&user=$key1&other=123")
|
||||
};
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual("value1", settings["key1"]);
|
||||
Assert.AreEqual("secret2", settings["key2"]);
|
||||
@@ -135,7 +136,39 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
|
||||
[Test]
|
||||
[Category("SecretHelper")]
|
||||
public void ReplacesSecretsWithUrlEscaping()
|
||||
public async Task ReplaceSecretsWithDashKeys()
|
||||
{
|
||||
var secretProvider = new MockedSecretProvider();
|
||||
secretProvider.Secrets["key1"] = "secret1";
|
||||
secretProvider.Secrets["key-with-dash"] = "secret2";
|
||||
|
||||
var settings = new Dictionary<string, string>
|
||||
{
|
||||
{"key1", "value1"},
|
||||
{"key2", "$key-with-dash"},
|
||||
{"key3", "value3$key"},
|
||||
};
|
||||
|
||||
var argsSys = new[] {
|
||||
new System.Uri("test://host/?pass=$key-with-dash&user=$key1&other=123")
|
||||
};
|
||||
|
||||
var argsInternal = new[] {
|
||||
new Library.Utility.Uri("test://host?pass=$key-with-dash&user=$key1&other=123")
|
||||
};
|
||||
|
||||
await SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual("value1", settings["key1"]);
|
||||
Assert.AreEqual("secret2", settings["key2"]);
|
||||
Assert.AreEqual("value3$key", settings["key3"]);
|
||||
Assert.AreEqual("test://host/?pass=secret2&user=secret1&other=123", argsSys[0].ToString());
|
||||
Assert.AreEqual("test://host?pass=secret2&user=secret1&other=123", argsInternal[0].ToString());
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("SecretHelper")]
|
||||
public async Task ReplacesSecretsWithUrlEscaping()
|
||||
{
|
||||
var secretProvider = new MockedSecretProvider();
|
||||
secretProvider.Secrets["key1"] = "secret 1%&+abc";
|
||||
@@ -153,7 +186,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
new Library.Utility.Uri("test://host?pass=$key1&user=user")
|
||||
};
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None);
|
||||
|
||||
// Check no escaping is done for options
|
||||
Assert.AreEqual("secret 1%&+abc", settings["key1"]);
|
||||
@@ -165,7 +198,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
|
||||
[Test]
|
||||
[Category("SecretHelper")]
|
||||
public void ReplaceSecretsWithExtendedPattern()
|
||||
public async Task ReplaceSecretsWithExtendedPattern()
|
||||
{
|
||||
var secretProvider = new MockedSecretProvider();
|
||||
secretProvider.Secrets["key1"] = "secret1";
|
||||
@@ -187,7 +220,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
new Library.Utility.Uri("test://host?pass=${key2}&user=${key1}&other=123"),
|
||||
};
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual("value1", settings["key1"]);
|
||||
Assert.AreEqual("secret2", settings["key2"]);
|
||||
@@ -198,7 +231,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
|
||||
[Test]
|
||||
[Category("SecretHelper")]
|
||||
public void ReplaceSecretsWithExtendedLongPattern()
|
||||
public async Task ReplaceSecretsWithExtendedLongPattern()
|
||||
{
|
||||
var secretProvider = new MockedSecretProvider();
|
||||
secretProvider.Secrets["key1"] = "secret1";
|
||||
@@ -220,7 +253,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
new Library.Utility.Uri("test://host?pass=:sec{key2}&user=:sec{key1}&other=123"),
|
||||
};
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual("value1", settings["key1"]);
|
||||
Assert.AreEqual("secret2", settings["key2"]);
|
||||
@@ -231,7 +264,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
|
||||
[Test]
|
||||
[Category("SecretHelper")]
|
||||
public void CachedProviderRetainsPattern()
|
||||
public async Task CachedProviderRetainsPattern()
|
||||
{
|
||||
var secretProvider = new MockedSecretProvider();
|
||||
secretProvider.Secrets["sec1"] = "secret1";
|
||||
@@ -242,15 +275,15 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
};
|
||||
|
||||
var cachedProvider = SecretProviderHelper.WrapWithCache("", secretProvider, SecretProviderHelper.CachingLevel.InMemory, null, "salt", "!ext{}");
|
||||
cachedProvider.InitializeAsync(new System.Uri("mock://"), CancellationToken.None).Await();
|
||||
await cachedProvider.InitializeAsync(new System.Uri("mock://"), CancellationToken.None);
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual("secret1", settings["key1"]);
|
||||
|
||||
settings["key1"] = "!ext{sec1}";
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual("secret1", settings["key1"]);
|
||||
}
|
||||
@@ -276,7 +309,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
new Library.Utility.Uri("test://host?pass=$key2&user=$key1&other=123"),
|
||||
};
|
||||
|
||||
Assert.Throws<KeyNotFoundException>(() => SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None).Await());
|
||||
Assert.ThrowsAsync<KeyNotFoundException>(() => SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -301,12 +334,12 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
new Library.Utility.Uri("test://host?pass=$key2&user=$key1&other=123"),
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None).Await());
|
||||
Assert.ThrowsAsync<InvalidOperationException>(() => SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("SecretHelper")]
|
||||
public void ReplaceUsesInMemoryCache()
|
||||
public async Task ReplaceUsesInMemoryCache()
|
||||
{
|
||||
var secretProvider = new MockedSecretProvider();
|
||||
secretProvider.Secrets["key1"] = "secret1";
|
||||
@@ -319,16 +352,16 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
};
|
||||
|
||||
var cachedProvider = SecretProviderHelper.WrapWithCache("", secretProvider, SecretProviderHelper.CachingLevel.InMemory, null, "salt", null);
|
||||
cachedProvider.InitializeAsync(new System.Uri("mock://"), CancellationToken.None).Await();
|
||||
await cachedProvider.InitializeAsync(new System.Uri("mock://"), CancellationToken.None);
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None);
|
||||
|
||||
secretProvider.Secrets = null;
|
||||
|
||||
settings["key1"] = "$key1";
|
||||
settings["key2"] = "$key2";
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual("secret1", settings["key1"]);
|
||||
Assert.AreEqual("secret2", settings["key2"]);
|
||||
@@ -336,7 +369,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
|
||||
[Test]
|
||||
[Category("SecretHelper")]
|
||||
public void ReplaceUsesPersistedCache()
|
||||
public async Task ReplaceUsesPersistedCache()
|
||||
{
|
||||
var secretProvider = new MockedSecretProvider();
|
||||
secretProvider.Secrets["key1"] = "secret1";
|
||||
@@ -350,16 +383,16 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
|
||||
using var tempFolder = new TempFolder();
|
||||
var cachedProvider = SecretProviderHelper.WrapWithCache("", secretProvider, SecretProviderHelper.CachingLevel.Persistent, tempFolder, "salt", null);
|
||||
cachedProvider.InitializeAsync(new System.Uri("mock://"), CancellationToken.None).Await();
|
||||
await cachedProvider.InitializeAsync(new System.Uri("mock://"), CancellationToken.None);
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None);
|
||||
|
||||
secretProvider.Secrets = null;
|
||||
|
||||
settings["key1"] = "$key1";
|
||||
settings["key2"] = "$key2";
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual("secret1", settings["key1"]);
|
||||
Assert.AreEqual("secret2", settings["key2"]);
|
||||
@@ -367,7 +400,7 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
|
||||
[Test]
|
||||
[Category("SecretHelper")]
|
||||
public void ReplaceUsesPersistedCacheOnRestart()
|
||||
public async Task ReplaceUsesPersistedCacheOnRestart()
|
||||
{
|
||||
var secretProvider = new MockedSecretProvider();
|
||||
secretProvider.Secrets["key1"] = "secret1";
|
||||
@@ -381,9 +414,9 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
|
||||
using var tempFolder = new TempFolder();
|
||||
var cachedProvider = SecretProviderHelper.WrapWithCache("", secretProvider, SecretProviderHelper.CachingLevel.Persistent, tempFolder, "salt", null);
|
||||
cachedProvider.InitializeAsync(new System.Uri("mock://"), CancellationToken.None).Await();
|
||||
await cachedProvider.InitializeAsync(new System.Uri("mock://"), CancellationToken.None);
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None);
|
||||
|
||||
settings["key1"] = "$key1";
|
||||
settings["key2"] = "$key2";
|
||||
@@ -393,12 +426,53 @@ public class SecretProviderHelperTests : BasicSetupHelper
|
||||
secretProvider.Secrets = null;
|
||||
secretProvider.ThrowOnInit = true;
|
||||
|
||||
cachedProvider.InitializeAsync(new System.Uri("mock://"), CancellationToken.None).Await();
|
||||
await cachedProvider.InitializeAsync(new System.Uri("mock://"), CancellationToken.None);
|
||||
|
||||
SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None).Await();
|
||||
await SecretProviderHelper.ApplySecretProviderAsync([], [], settings, null, cachedProvider, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual("secret1", settings["key1"]);
|
||||
Assert.AreEqual("secret2", settings["key2"]);
|
||||
}
|
||||
|
||||
private class LogCapture : ILogDestination
|
||||
{
|
||||
public List<string> Messages { get; } = new();
|
||||
|
||||
public void WriteMessage(LogEntry entry)
|
||||
{
|
||||
Messages.Add(entry.AsString(true));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("SecretHelper")]
|
||||
public async Task ReplaceWarnsOnPartialMatches()
|
||||
{
|
||||
var secretProvider = new MockedSecretProvider();
|
||||
secretProvider.Secrets["key1"] = "secret1";
|
||||
secretProvider.Secrets["key2"] = "secret2";
|
||||
|
||||
var settings = new Dictionary<string, string>
|
||||
{
|
||||
{"key1", "value1"},
|
||||
{"key2", "$key)3"},
|
||||
};
|
||||
|
||||
var argsSys = new[] {
|
||||
new System.Uri("test://host?pass=$key2&user=$key)4&other=123"),
|
||||
};
|
||||
|
||||
var argsInternal = new[] {
|
||||
new Library.Utility.Uri("test://host?pass=$key2&user=$key)5&other=123"),
|
||||
};
|
||||
|
||||
var logCapture = new LogCapture();
|
||||
using var _ = Log.StartScope(logCapture, LogMessageType.Warning);
|
||||
await SecretProviderHelper.ApplySecretProviderAsync(argsSys, argsInternal, settings, null, secretProvider, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual("value1", settings["key1"]);
|
||||
Assert.AreEqual(logCapture.Messages.Count, 1);
|
||||
Assert.That(logCapture.Messages[0].Contains("Found 3 partial"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user