diff --git a/Duplicati/Library/Main/Options.cs b/Duplicati/Library/Main/Options.cs index 6a9a018d0..81319af3c 100644 --- a/Duplicati/Library/Main/Options.cs +++ b/Duplicati/Library/Main/Options.cs @@ -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(), ]; diff --git a/Duplicati/Library/Main/Strings.cs b/Duplicati/Library/Main/Strings.cs index 7cde4102f..1992ab36f 100644 --- a/Duplicati/Library/Main/Strings.cs +++ b/Duplicati/Library/Main/Strings.cs @@ -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 diff --git a/Duplicati/Library/Modules/Builtin/ReportHelper.cs b/Duplicati/Library/Modules/Builtin/ReportHelper.cs index 218d9b913..d2a5fde35 100644 --- a/Duplicati/Library/Modules/Builtin/ReportHelper.cs +++ b/Duplicati/Library/Modules/Builtin/ReportHelper.cs @@ -89,6 +89,11 @@ namespace Duplicati.Library.Modules.Builtin /// protected abstract string ExtraDataOptionName { get; } + /// + /// The option used to disable path redaction in log messages, mirrored from Options.cs + /// + protected const string OPTION_ALLOW_PATHS_IN_LOG_MESSAGES = "allow-paths-in-log-messages"; + /// /// The default subject or title line /// @@ -205,6 +210,11 @@ namespace Duplicati.Library.Modules.Builtin /// private IResultFormatSerializer m_resultFormatSerializer; + /// + /// True if paths are allowed in log messages + /// + private bool m_allowPathsInLogMessages; + /// /// Configures the module /// @@ -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) diff --git a/Duplicati/Library/UsageReporter/Reporter.cs b/Duplicati/Library/UsageReporter/Reporter.cs index c8da26072..f919f60f6 100644 --- a/Duplicati/Library/UsageReporter/Reporter.cs +++ b/Duplicati/Library/UsageReporter/Reporter.cs @@ -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 { } } diff --git a/Duplicati/Library/Utility/SensitiveDataFilter.cs b/Duplicati/Library/Utility/SensitiveDataFilter.cs new file mode 100644 index 000000000..0112df173 --- /dev/null +++ b/Duplicati/Library/Utility/SensitiveDataFilter.cs @@ -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 +{ + /// + /// Helper class to filter sensitive data from strings + /// + public static class SensitiveDataFilter + { + /// + /// Regex that detects file system paths in text + /// + private static readonly Regex PathDetectionRegex = new Regex( + @"(? + /// Characters that are commonly trailing delimiters and should not be part of a path + /// + private static readonly char[] PathTrimChars = new[] { '"', '\'', '(', ')', '[', ']', '{', '}', '<', '>', ',', ';', ':' }; + + /// + /// Replaces detected file system paths with "-redacted-" + /// + /// The input string to filter + /// The filtered string with paths redacted + 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-"; + }); + } + } +} diff --git a/Duplicati/UnitTest/SensitiveDataFilterTests.cs b/Duplicati/UnitTest/SensitiveDataFilterTests.cs new file mode 100644 index 000000000..de426197e --- /dev/null +++ b/Duplicati/UnitTest/SensitiveDataFilterTests.cs @@ -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("")); + } + } +}