Fitler paths in reported log data

This PR introduces a filter that redacts paths in log messages that are sent outside the current machine. This reduces the possibility that sensitive information is accidentially reported to an external party.

For self-hosted solutions, the option `--allow-paths-in-log-messages=true` will skip the redaction step and report the original path.
This commit is contained in:
Kenneth Skovhede
2026-06-17 09:44:13 +02:00
parent 59be19660c
commit 6db979f702
6 changed files with 229 additions and 2 deletions
+2
View File
@@ -606,6 +606,8 @@ namespace Duplicati.Library.Main
new CommandLineArgument("internal-profiling", CommandLineArgument.ArgumentType.Boolean, Strings.Options.InternalProfilingShort, Strings.Options.InternalProfilingLong, "false"),
new CommandLineArgument("ignore-update-if-version-exists", CommandLineArgument.ArgumentType.Boolean, Strings.Options.IgnoreUpdateIfVersionExistsShort, Strings.Options.IgnoreUpdateIfVersionExistsLong, "false"),
new CommandLineArgument("allow-paths-in-log-messages", CommandLineArgument.ArgumentType.Boolean, Strings.Options.AllowPathsInLogMessagesShort, Strings.Options.AllowPathsInLogMessagesLong, "false"),
.. GetOSConditionalCommands(),
.. GetDebugConditionalCommands(),
];
+2
View File
@@ -410,6 +410,8 @@ namespace Duplicati.Library.Main.Strings
public static string EnableAdsBackupLong { get { return LC.L("Use this option to enable backup of NTFS alternate data streams (ADS). This is a Windows-only feature that allows backing up hidden data attached to files."); } }
public static string DisableAdsRestoreShort { get { return LC.L("Disable restore of NTFS alternate data streams"); } }
public static string DisableAdsRestoreLong { get { return LC.L("Use this option to skip restoring NTFS alternate data streams (ADS) during a restore operation. The main file content will still be restored."); } }
public static string AllowPathsInLogMessagesShort { get { return LC.L("Allow paths in log messages"); } }
public static string AllowPathsInLogMessagesLong { get { return LC.L("Use this option to allow paths to be included in log messages sent to remote servers. By default, paths are redacted to protect sensitive information."); } }
}
internal static class Common
@@ -89,6 +89,11 @@ namespace Duplicati.Library.Modules.Builtin
/// </summary>
protected abstract string ExtraDataOptionName { get; }
/// <summary>
/// The option used to disable path redaction in log messages, mirrored from Options.cs
/// </summary>
protected const string OPTION_ALLOW_PATHS_IN_LOG_MESSAGES = "allow-paths-in-log-messages";
/// <summary>
/// The default subject or title line
/// </summary>
@@ -205,6 +210,11 @@ namespace Duplicati.Library.Modules.Builtin
/// </summary>
private IResultFormatSerializer m_resultFormatSerializer;
/// <summary>
/// True if paths are allowed in log messages
/// </summary>
private bool m_allowPathsInLogMessages;
/// <summary>
/// Configures the module
/// </summary>
@@ -232,6 +242,7 @@ namespace Duplicati.Library.Modules.Builtin
m_options = commandlineOptions.AsReadOnly();
m_isConfigured = true;
m_allowPathsInLogMessages = Utility.Utility.ParseBoolOption(m_options, OPTION_ALLOW_PATHS_IN_LOG_MESSAGES);
m_options.TryGetValue(SubjectOptionName, out m_subject);
m_options.TryGetValue(BodyOptionName, out m_body);
m_options.TryGetValue(ExtraDataOptionName, out var extraData);
@@ -547,6 +558,9 @@ namespace Duplicati.Library.Modules.Builtin
logdata = logdata.Concat(new string[] { $"... and {m_logstorage.Count - m_maxmimumLogLines} more" });
}
if (!m_allowPathsInLogMessages)
logdata = logdata.Select(x => SensitiveDataFilter.RedactPaths(x));
return logdata;
}
}
@@ -608,6 +622,12 @@ namespace Duplicati.Library.Modules.Builtin
body = ReplaceTemplate(body, result, exception, false);
subject = ReplaceTemplate(subject, result, exception, true);
if (!m_allowPathsInLogMessages)
{
body = SensitiveDataFilter.RedactPaths(body);
subject = SensitiveDataFilter.RedactPaths(subject);
}
SendMessage(subject, body);
}
catch (Exception ex)
+2 -2
View File
@@ -55,7 +55,7 @@ namespace Duplicati.Library.UsageReporter
public static void Report(string key, string data = null, ReportType type = ReportType.Information)
{
if (_eventChannel != null && type >= MaxReportLevel)
try { _eventChannel.WriteNoWait(new ReportItem(type, null, key, data)); }
try { _eventChannel.WriteNoWait(new ReportItem(type, null, key, SensitiveDataFilter.RedactPaths(data))); }
catch { }
}
@@ -80,7 +80,7 @@ namespace Duplicati.Library.UsageReporter
public static void Report(Exception ex, ReportType type = ReportType.Warning)
{
if (_eventChannel != null && type >= MaxReportLevel)
try { _eventChannel.WriteNoWait(new ReportItem(type, null, "EXCEPTION", ex.ToString())); }
try { _eventChannel.WriteNoWait(new ReportItem(type, null, "EXCEPTION", SensitiveDataFilter.RedactPaths(ex.ToString()))); }
catch { }
}
@@ -0,0 +1,76 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Text.RegularExpressions;
namespace Duplicati.Library.Utility
{
/// <summary>
/// Helper class to filter sensitive data from strings
/// </summary>
public static class SensitiveDataFilter
{
/// <summary>
/// Regex that detects file system paths in text
/// </summary>
private static readonly Regex PathDetectionRegex = new Regex(
@"(?<![\w/\\])" +
@"(?:" +
@"(?:[a-zA-Z]:\\(?:[^\\\s\n\r]*\\?)*)" + // Windows drive paths
@"|(?:\\\\[^\\\s\n\r]+(?:\\[^\\\s\n\r]*)*)" + // UNC paths
@"|(?:/[a-zA-Z0-9_.][^/\s\n\r]*(?:/[a-zA-Z0-9_.][^/\s\n\r]*)*)" + // Unix absolute paths
@"|(?:file:///[^\s\n\r]*)" + // File URIs
@")",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline);
/// <summary>
/// Characters that are commonly trailing delimiters and should not be part of a path
/// </summary>
private static readonly char[] PathTrimChars = new[] { '"', '\'', '(', ')', '[', ']', '{', '}', '<', '>', ',', ';', ':' };
/// <summary>
/// Replaces detected file system paths with "-redacted-"
/// </summary>
/// <param name="input">The input string to filter</param>
/// <returns>The filtered string with paths redacted</returns>
public static string RedactPaths(string input)
{
if (string.IsNullOrEmpty(input))
return input;
return PathDetectionRegex.Replace(input, match =>
{
var value = match.Value;
// Trim trailing punctuation that is likely not part of the path
int end = value.Length;
while (end > 0 && Array.IndexOf(PathTrimChars, value[end - 1]) >= 0)
end--;
if (end < value.Length)
return "-redacted-" + value.Substring(end);
return "-redacted-";
});
}
}
}
@@ -0,0 +1,127 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using NUnit.Framework;
using Duplicati.Library.Utility;
namespace Duplicati.UnitTest
{
[TestFixture]
public class SensitiveDataFilterTests
{
[Test]
public void RedactPaths_UnixPaths()
{
var input = "Failed to access /home/user/secret.txt";
var result = SensitiveDataFilter.RedactPaths(input);
Assert.That(result, Is.EqualTo("Failed to access -redacted-"));
}
[Test]
public void RedactPaths_WindowsPaths()
{
var input = "Failed to access C:\\Users\\user\\secret.txt";
var result = SensitiveDataFilter.RedactPaths(input);
Assert.That(result, Is.EqualTo("Failed to access -redacted-"));
}
[Test]
public void RedactPaths_UNCPaths()
{
var input = "Failed to access \\\\server\\share\\folder\\file.txt";
var result = SensitiveDataFilter.RedactPaths(input);
Assert.That(result, Is.EqualTo("Failed to access -redacted-"));
}
[Test]
public void RedactPaths_FileUris()
{
var input = "Found file:///home/user/secret.txt";
var result = SensitiveDataFilter.RedactPaths(input);
Assert.That(result, Is.EqualTo("Found -redacted-"));
}
[Test]
public void RedactPaths_MultiplePaths()
{
var input = "Paths: /home/user/secret.txt and C:\\Users\\user\\other.txt";
var result = SensitiveDataFilter.RedactPaths(input);
Assert.That(result, Is.EqualTo("Paths: -redacted- and -redacted-"));
}
[Test]
public void RedactPaths_PathsInQuotes()
{
var input = "Path \"/home/user/secret.txt\" not found";
var result = SensitiveDataFilter.RedactPaths(input);
Assert.That(result, Is.EqualTo("Path \"-redacted-\" not found"));
}
[Test]
public void RedactPaths_PathsInParens()
{
var input = "File (/home/user/secret.txt) missing";
var result = SensitiveDataFilter.RedactPaths(input);
Assert.That(result, Is.EqualTo("File (-redacted-) missing"));
}
[Test]
public void RedactPaths_StackTrace()
{
var input = "at Duplicati.Main.Run() in /Users/builder/project/Duplicati/Main.cs:line 42";
var result = SensitiveDataFilter.RedactPaths(input);
// The path is redacted; trailing stack trace metadata may also be consumed
Assert.That(result, Does.Contain("-redacted-"));
Assert.That(result, Does.Not.Contain("/Users/builder/project/Duplicati/Main.cs"));
}
[Test]
public void RedactPaths_DoesNotRedactUrls()
{
var input = "Visit https://example.com/path/to/resource";
var result = SensitiveDataFilter.RedactPaths(input);
Assert.That(result, Is.EqualTo("Visit https://example.com/path/to/resource"));
}
[Test]
public void RedactPaths_DoesNotRedactDates()
{
var input = "Date: 2026/01/01";
var result = SensitiveDataFilter.RedactPaths(input);
Assert.That(result, Is.EqualTo("Date: 2026/01/01"));
}
[Test]
public void RedactPaths_DoesNotRedactDivision()
{
var input = "Result: 100 / 200";
var result = SensitiveDataFilter.RedactPaths(input);
Assert.That(result, Is.EqualTo("Result: 100 / 200"));
}
[Test]
public void RedactPaths_EmptyInput()
{
Assert.That(SensitiveDataFilter.RedactPaths(null), Is.Null);
Assert.That(SensitiveDataFilter.RedactPaths(""), Is.EqualTo(""));
}
}
}