diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json
index beac2807af..d5796aa281 100644
--- a/src/Languages/lang_en.json
+++ b/src/Languages/lang_en.json
@@ -1701,5 +1701,32 @@
"Search these package managers": "Search these package managers",
"No package managers are available": "No package managers are available",
"{0} sources": "{0} sources",
- "No sources": "No sources"
+ "No sources": "No sources",
+ "This bundle changes how packages are installed": "This bundle changes how packages are installed",
+ "Review the findings below. The packages will not be added to the bundle unless you continue.": "Review the findings below. The packages will not be added to the bundle unless you continue.",
+ "Import anyway": "Import anyway",
+ "High risk": "High risk",
+ "Removed": "Removed",
+ "Values marked Removed were stripped during import. You can allow them under Settings > {0}": "Values marked Removed were stripped during import. You can allow them under Settings > {0}",
+ "{0} packages": "{0} packages",
+ "{0} high risk": "{0} high risk",
+ "{0} informational": "{0} informational",
+ "Some packages use non-default install settings": "Some packages use non-default install settings",
+ "Nothing was blocked. These entries are listed so you know what the bundle asked for.": "Nothing was blocked. These entries are listed so you know what the bundle asked for.",
+ "Other packages with informational findings ({0})": "Other packages with informational findings ({0})",
+ "Custom install arguments": "Custom install arguments",
+ "Custom update arguments": "Custom update arguments",
+ "Custom uninstall arguments": "Custom uninstall arguments",
+ "Pre-install command": "Pre-install command",
+ "Post-install command": "Post-install command",
+ "Pre-update command": "Pre-update command",
+ "Post-update command": "Post-update command",
+ "Pre-uninstall command": "Pre-uninstall command",
+ "Post-uninstall command": "Post-uninstall command",
+ "Requested version": "Requested version",
+ "Installer integrity check disabled": "Installer integrity check disabled",
+ "Runs elevated": "Runs elevated",
+ "Processes terminated before the operation": "Processes terminated before the operation",
+ "Unknown package source": "Unknown package source",
+ "Non-default package source": "Non-default package source"
}
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/BundleSecurityReportDialog.axaml b/src/UniGetUI.Avalonia/Views/DialogPages/BundleSecurityReportDialog.axaml
index cf78b64f84..218bbba453 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/BundleSecurityReportDialog.axaml
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/BundleSecurityReportDialog.axaml
@@ -5,38 +5,164 @@
xmlns:t="using:UniGetUI.Avalonia.MarkupExtensions"
x:Class="UniGetUI.Avalonia.Views.DialogPages.BundleSecurityReportDialog"
Title="{t:Translate Bundle security report}"
- MaxWidth="580"
- MinWidth="400"
- MaxHeight="420"
- MinHeight="300"
+ MaxWidth="640"
+ MinWidth="440"
+ MaxHeight="540"
+ MinHeight="320"
Background="{DynamicResource AppDialogBackground}">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/BundleSecurityReportDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/BundleSecurityReportDialog.axaml.cs
index 5c01bdfc2e..d105493273 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/BundleSecurityReportDialog.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/BundleSecurityReportDialog.axaml.cs
@@ -1,30 +1,239 @@
+using Avalonia.Automation;
using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
using Avalonia.Threading;
+using UniGetUI.Core.Tools;
using UniGetUI.Interface.Enums;
namespace UniGetUI.Avalonia.Views.DialogPages;
public partial class BundleSecurityReportDialog : UniGetUI.Avalonia.Views.DialogPages.ImmersiveDialog
{
+ private readonly bool _gated;
+
+ public bool Accepted { get; private set; }
+
public BundleSecurityReportDialog(BundleReport report)
{
InitializeComponent();
- var sb = new System.Text.StringBuilder();
- foreach (var (pkgId, entries) in report.Contents)
+ _gated = report.HasHighSeverityFindings;
+ Accepted = !_gated;
+
+ HeaderText.Text = _gated
+ ? CoreTools.Translate("This bundle changes how packages are installed")
+ : CoreTools.Translate("Some packages use non-default install settings");
+
+ SubHeaderText.Text = _gated
+ ? CoreTools.Translate("Review the findings below. The packages will not be added to the bundle unless you continue.")
+ : CoreTools.Translate("Nothing was blocked. These entries are listed so you know what the bundle asked for.");
+
+ IntroIcon.Classes.Add("intro-icon");
+ IntroIcon.Classes.Add(_gated ? "high" : "info");
+ IntroIcon.Data = Geometry.Parse(_gated ? WarningGlyph : InfoGlyph);
+
+ AddSummaryChip(
+ CoreTools.Translate("{0} packages", report.Contents.Count), "neutral");
+ if (report.HighSeverityCount > 0)
+ AddSummaryChip(
+ CoreTools.Translate("{0} high risk", report.HighSeverityCount), "high");
+ if (report.InformationalCount > 0)
+ AddSummaryChip(
+ CoreTools.Translate("{0} informational", report.InformationalCount), "info");
+
+ FootnoteText.Classes.Add("report-footnote");
+ FootnoteText.IsVisible = report.HasSettingControlledStripping;
+ FootnoteText.Text = CoreTools.Translate(
+ "Values marked Removed were stripped during import. You can allow them under Settings > {0}",
+ CoreTools.Translate("Administrator rights and other dangerous settings"));
+
+ PopulateFindings(report);
+
+ string primaryLabel = _gated
+ ? CoreTools.Translate("Import anyway")
+ : CoreTools.Translate("OK");
+ PrimaryButton.Content = primaryLabel;
+ AutomationProperties.SetName(PrimaryButton, primaryLabel);
+ CancelButton.IsVisible = _gated;
+
+ PrimaryButton.Click += (_, _) => Complete(true);
+ CancelButton.Click += (_, _) => Complete(false);
+ }
+
+ private void PopulateFindings(BundleReport report)
+ {
+ List risky = [];
+ List informational = [];
+ foreach (var package in report.Contents.Values)
+ (package.HasHighSeverityFindings ? risky : informational).Add(package);
+
+ risky.Sort(CompareByDisplayName);
+ informational.Sort(CompareByDisplayName);
+
+ foreach (var package in risky)
+ FindingsHost.Children.Add(BuildPackageSection(package));
+
+ if (informational.Count is 0)
+ return;
+
+ if (risky.Count is 0)
+ foreach (var package in informational)
+ FindingsHost.Children.Add(BuildPackageSection(package));
+ else
+ FindingsHost.Children.Add(BuildInformationalGroup(informational));
+ }
+
+ private static int CompareByDisplayName(BundleReportPackage left, BundleReportPackage right)
+ => StringComparer.OrdinalIgnoreCase.Compare(
+ left.Subject.DisplayName, right.Subject.DisplayName);
+
+ private static Control BuildInformationalGroup(List packages)
+ {
+ var content = new StackPanel
+ {
+ Spacing = 16,
+ Margin = new global::Avalonia.Thickness(0, 10, 0, 2),
+ };
+ foreach (var package in packages)
+ content.Children.Add(BuildPackageSection(package));
+
+ var header = new TextBlock
{
- sb.AppendLine($"• {pkgId}:");
- foreach (var entry in entries)
- sb.AppendLine($" {(entry.Allowed ? "[allowed]" : "[stripped]")} {entry.Line}");
+ Text = CoreTools.Translate(
+ "Other packages with informational findings ({0})", packages.Count),
+ };
+ header.Classes.Add("group-header");
+
+ return new Expander
+ {
+ Header = header,
+ Content = content,
+ IsExpanded = false,
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ HorizontalContentAlignment = HorizontalAlignment.Stretch,
+ };
+ }
+
+ private const string WarningGlyph =
+ "M12 2 L23 21 L1 21 Z M11 9 h2 v6 h-2 Z M11 17 h2 v2 h-2 Z";
+
+ private const string InfoGlyph =
+ "M2 12 A10 10 0 1 1 22 12 A10 10 0 1 1 2 12 Z M11 6 h2 v2 h-2 Z M11 10 h2 v8 h-2 Z";
+
+ private void AddSummaryChip(string text, string variantClass)
+ {
+ var chip = BuildTag(text, variantClass);
+ chip.Margin = new global::Avalonia.Thickness(0, 0, 8, 0);
+ SummaryHost.Children.Add(chip);
+ }
+
+ private static Control BuildPackageSection(BundleReportPackage package)
+ {
+ var section = new StackPanel { Spacing = 8 };
+
+ var title = new TextBlock { Text = package.Subject.DisplayName };
+ title.Classes.Add("package-header");
+ var rule = new Border();
+ rule.Classes.Add("package-rule");
+
+ var header = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*") };
+ Grid.SetColumn(title, 0);
+ Grid.SetColumn(rule, 1);
+ header.Children.Add(title);
+ header.Children.Add(rule);
+ section.Children.Add(header);
+
+ var identity = new TextBlock { Text = DescribeIdentity(package.Subject) };
+ identity.Classes.Add("package-subheader");
+ section.Children.Add(identity);
+
+ var ordered = package
+ .Entries.OrderByDescending(entry => entry.Severity)
+ .ThenBy(entry => entry.Field, StringComparer.Ordinal);
+
+ foreach (var entry in ordered)
+ section.Children.Add(BuildFindingCard(entry));
+
+ return section;
+ }
+
+ private static string DescribeIdentity(BundleReportSubject subject)
+ {
+ var parts = new List { subject.Id };
+ if (subject.ManagerName.Length > 0)
+ parts.Add(subject.ManagerName);
+ if (subject.Source.Length > 0)
+ parts.Add(subject.Source);
+ return string.Join(" · ", parts);
+ }
+
+ private static Control BuildFindingCard(BundleReportEntry entry)
+ {
+ bool high = entry.Severity is BundleReportSeverity.High;
+ string severityClass = high ? "high" : "info";
+
+ var body = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
+ var label = new TextBlock { Text = CoreTools.Translate(entry.Label) };
+ label.Classes.Add("finding-label");
+ body.Children.Add(label);
+
+ if (entry.LineCarriesTheValue)
+ {
+ var value = new TextBlock { Text = entry.Value };
+ value.Classes.Add("finding-value");
+ body.Children.Add(value);
+ }
+
+ var row = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
+ ColumnSpacing = 10,
+ };
+
+ var severityTag = BuildTag(
+ high ? CoreTools.Translate("High risk") : CoreTools.Translate("Info"),
+ severityClass);
+ Grid.SetColumn(severityTag, 0);
+ Grid.SetColumn(body, 1);
+ row.Children.Add(severityTag);
+ row.Children.Add(body);
+
+ if (!entry.Allowed)
+ {
+ var removedTag = BuildTag(CoreTools.Translate("Removed"), "neutral");
+ Grid.SetColumn(removedTag, 2);
+ row.Children.Add(removedTag);
}
- ReportText.Text = sb.ToString();
- OkButton.Click += (_, _) => Close();
+ var card = new Border { Child = row };
+ card.Classes.Add("finding");
+ return card;
+ }
+
+ private static Border BuildTag(string text, string variantClass)
+ {
+ var caption = new TextBlock { Text = text };
+ caption.Classes.Add("tag-text");
+ caption.Classes.Add(variantClass);
+
+ var tag = new Border { Child = caption };
+ tag.Classes.Add("tag");
+ tag.Classes.Add(variantClass);
+ return tag;
+ }
+
+ private void Complete(bool accepted)
+ {
+ Accepted = accepted;
+ Close();
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
- Dispatcher.UIThread.Post(() => OkButton.Focus(), DispatcherPriority.Background);
+ Dispatcher.UIThread.Post(
+ () => (_gated ? CancelButton : PrimaryButton).Focus(),
+ DispatcherPriority.Background);
}
}
diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs
index 35dddb9ee6..33bf75edf1 100644
--- a/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs
+++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs
@@ -242,12 +242,20 @@ protected override async Task ShowInstallationOptionsForPackage(IPackage? packag
// ─── Bundle operations ────────────────────────────────────────────────────
public async Task AskForNewBundle()
{
- if (_loader.Any() && HasUnsavedChanges && !await AskLoseChanges())
+ if (!await ConfirmDiscardingCurrentBundle())
return false;
+ ClearCurrentBundle();
+ return true;
+ }
+
+ private async Task ConfirmDiscardingCurrentBundle()
+ => !(_loader.Any() && HasUnsavedChanges) || await AskLoseChanges();
+
+ private void ClearCurrentBundle()
+ {
_loader.ClearPackages();
HasUnsavedChanges = false;
- return true;
}
public async Task ImportAndInstallPackage(
@@ -312,17 +320,17 @@ await ShowErrorDialog(win,
public async Task OpenFromString(string payload, BundleFormatType format, string source, int? _loadingId = null)
{
- if (!await AskForNewBundle()) return;
+ if (!await ConfirmDiscardingCurrentBundle()) return;
+
+ var (openVersion, report, imported) = await AddFromBundle(
+ payload, format, ShowBundleSecurityReport, replaceExisting: true, source: source);
+ if (!imported) return;
- var (openVersion, report) = await AddFromBundle(payload, format);
TelemetryHandler.ImportBundle(format);
HasUnsavedChanges = false;
if ((int)(openVersion * 10) != (int)(SerializableBundle.ExpectedVersion * 10))
Logger.Warn($"Bundle \"{source}\" uses schema version {openVersion}, expected {SerializableBundle.ExpectedVersion}.");
-
- if (!report.IsEmpty && GetMainWindow() is { } win)
- await ShowBundleSecurityReport(win, report);
}
/// Compatibility overload matching the legacy stub signature.
@@ -333,7 +341,6 @@ public Task OpenFromString(string payload, object format, string source, int loa
public async Task AskOpenFromFile()
{
- if (!await AskForNewBundle()) return;
if (GetMainWindow() is not { } win) return;
var files = await win.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
@@ -416,7 +423,12 @@ public static async Task CreateBundle(IReadOnlyList unsortedPa
return exportableData.AsJsonString();
}
- public async Task<(double, BundleReport)> AddFromBundle(string content, BundleFormatType format)
+ public async Task<(double Version, BundleReport Report, bool Imported)> AddFromBundle(
+ string content,
+ BundleFormatType format,
+ Func>? acknowledge = null,
+ bool replaceExisting = false,
+ string source = "")
{
if (format is BundleFormatType.YAML)
{
@@ -440,18 +452,37 @@ public static async Task CreateBundle(IReadOnlyList unsortedPa
var packages = new List();
foreach (var pkg in deserializedData.packages)
{
+ var manager = ResolveManagerForImport(pkg.ManagerName);
+ var (sourceName, sourceStatus) = BundleImportFilter.ClassifySource(manager, pkg.Source);
pkg.InstallationOptions = BundleImportFilter.Apply(
- ref report, pkg.Id, pkg.InstallationOptions, allowCLI, allowPrePost,
- ResolveManagerForImport(pkg.ManagerName)?.CommandLineIsShellInterpreted ?? false);
+ ref report,
+ new BundleReportSubject(
+ pkg.Id, pkg.Name, manager?.DisplayName ?? pkg.ManagerName, sourceName),
+ pkg.InstallationOptions, allowCLI, allowPrePost,
+ manager?.CommandLineIsShellInterpreted ?? false, sourceName, sourceStatus);
packages.Add(DeserializePackage(pkg));
}
foreach (var pkg in deserializedData.incompatible_packages)
packages.Add(DeserializeIncompatiblePackage(pkg, NullSource.Instance));
+ BundleImportFilter.LogReport(report, source);
+
+ if (!report.IsEmpty && acknowledge is not null && !await acknowledge(report))
+ {
+ Logger.Warn("The bundle import was discarded by the user after the security report");
+ return (deserializedData.export_version, report, false);
+ }
+
+ if (report.HasHighSeverityFindings)
+ Logger.Warn("The user accepted a bundle carrying high-severity security findings");
+
+ if (replaceExisting)
+ ClearCurrentBundle();
+
await PackageBundlesLoader.Instance.AddPackagesAsync(packages);
- return (deserializedData.export_version, report);
+ return (deserializedData.export_version, report, true);
}
private static IPackageManager? ResolveManagerForImport(string managerName)
@@ -678,8 +709,14 @@ private static async Task AskLoseChanges()
return dialog.Confirmed;
}
- private static async Task ShowBundleSecurityReport(Window owner, BundleReport report)
- => await new BundleSecurityReportDialog(report).ShowDialog(owner);
+ private static async Task ShowBundleSecurityReport(BundleReport report)
+ {
+ if (GetMainWindow() is not { } owner) return !report.HasHighSeverityFindings;
+
+ var dialog = new BundleSecurityReportDialog(report);
+ await dialog.ShowDialog(owner);
+ return dialog.Accepted;
+ }
private static async Task ShowErrorDialog(Window owner, string title, string message)
=> await new SimpleErrorDialog(title, message).ShowDialog(owner);
diff --git a/src/UniGetUI.Interface.Enums/Enums.cs b/src/UniGetUI.Interface.Enums/Enums.cs
index 09e25a4e91..c2484af452 100644
--- a/src/UniGetUI.Interface.Enums/Enums.cs
+++ b/src/UniGetUI.Interface.Enums/Enums.cs
@@ -101,23 +101,98 @@ public class NotificationArguments
public const string ReleaseSelfUpdateLock = "releaseSelfUpdateLock";
}
+ public enum BundleReportSeverity
+ {
+ Info = 0,
+ High = 1,
+ }
+
+ public enum BundleSourceStatus
+ {
+ Default = 0,
+ Known = 1,
+ Unknown = 2,
+ }
+
public struct BundleReportEntry
{
+ public readonly string Field;
+ public readonly string Label;
+ public readonly string Value;
public readonly string Line;
+ public readonly BundleReportSeverity Severity;
public readonly bool Allowed;
+ public readonly bool StrippedBySetting;
- public BundleReportEntry(string line, bool allowed)
+ public BundleReportEntry(
+ string field,
+ string label,
+ string value,
+ string line,
+ BundleReportSeverity severity,
+ bool allowed,
+ bool strippedBySetting = false)
{
+ Field = field;
+ Label = label;
+ Value = value;
Line = line;
+ Severity = severity;
Allowed = allowed;
+ StrippedBySetting = strippedBySetting;
+ }
+
+ public readonly bool LineCarriesTheValue => Line != Label;
+ }
+
+ public readonly record struct BundleReportSubject(
+ string Id,
+ string Name,
+ string ManagerName,
+ string Source)
+ {
+ public string DisplayName => Name.Length is 0 ? Id : Name;
+
+ public string Key => $"{ManagerName}\\{Source}\\{Id}";
+ }
+
+ public sealed class BundleReportPackage
+ {
+ public BundleReportSubject Subject { get; }
+ public List Entries { get; } = [];
+
+ public BundleReportPackage(BundleReportSubject subject)
+ {
+ Subject = subject;
}
+
+ public bool HasHighSeverityFindings
+ => Entries.Any(entry => entry.Severity is BundleReportSeverity.High);
}
public struct BundleReport
{
public bool IsEmpty = false;
- public Dictionary> Contents = new();
+ public Dictionary Contents = new();
public BundleReport() { }
+
+ public readonly IEnumerable AllEntries
+ => Contents.Values.SelectMany(package => package.Entries);
+
+ public readonly bool HasHighSeverityFindings
+ => AllEntries.Any(entry => entry.Severity is BundleReportSeverity.High);
+
+ public readonly bool HasStrippedFindings
+ => AllEntries.Any(entry => !entry.Allowed);
+
+ public readonly bool HasSettingControlledStripping
+ => AllEntries.Any(entry => entry.StrippedBySetting);
+
+ public readonly int HighSeverityCount
+ => AllEntries.Count(entry => entry.Severity is BundleReportSeverity.High);
+
+ public readonly int InformationalCount
+ => AllEntries.Count(entry => entry.Severity is BundleReportSeverity.Info);
}
}
diff --git a/src/UniGetUI.Interface.IpcApi/IpcBundleApi.cs b/src/UniGetUI.Interface.IpcApi/IpcBundleApi.cs
index 3e0a86d143..82b29afaad 100644
--- a/src/UniGetUI.Interface.IpcApi/IpcBundleApi.cs
+++ b/src/UniGetUI.Interface.IpcApi/IpcBundleApi.cs
@@ -73,6 +73,12 @@ public sealed class IpcBundleInstallRequest
public sealed class IpcBundleSecurityEntry
{
public string PackageId { get; set; } = "";
+ public string ManagerName { get; set; } = "";
+ public string Source { get; set; } = "";
+ public string Severity { get; set; } = "";
+ public string Field { get; set; } = "";
+ public string Label { get; set; } = "";
+ public string Value { get; set; } = "";
public string Line { get; set; } = "";
public bool Allowed { get; set; }
}
@@ -617,15 +623,25 @@ BundleFormatType format
List packages = [];
foreach (var package in deserializedData.packages)
{
+ var manager = IpcManagerSettingsApi.ResolveImportedManager(package.ManagerName);
+ var (sourceName, sourceStatus) = BundleImportFilter.ClassifySource(
+ manager,
+ package.Source
+ );
package.InstallationOptions = BundleImportFilter.Apply(
ref report,
- package.Id,
+ new BundleReportSubject(
+ package.Id,
+ package.Name,
+ manager?.DisplayName ?? package.ManagerName,
+ sourceName
+ ),
package.InstallationOptions,
allowCliArguments,
allowPrePostCommands,
- IpcManagerSettingsApi.ResolveImportedManager(package.ManagerName)
- ?.CommandLineIsShellInterpreted
- ?? false
+ manager?.CommandLineIsShellInterpreted ?? false,
+ sourceName,
+ sourceStatus
);
packages.Add(DeserializePackage(package));
}
@@ -635,6 +651,8 @@ BundleFormatType format
packages.Add(new InvalidImportedPackage(incompatiblePackage, NullSource.Instance));
}
+ BundleImportFilter.LogReport(report, "IPC bundle import");
+
await GetLoader().AddPackagesAsync(packages);
return (deserializedData.export_version, report);
}
@@ -675,15 +693,26 @@ private static IReadOnlyList FlattenReport(BundleReport
return report
.Contents.SelectMany(pair =>
- pair.Value.Select(entry => new IpcBundleSecurityEntry
- {
- PackageId = pair.Key,
- Line = entry.Line,
- Allowed = entry.Allowed,
- })
+ pair.Value.Entries.Select(entry =>
+ (PackageId: pair.Value.Subject, Entry: entry)
+ )
)
- .OrderBy(entry => entry.PackageId, StringComparer.OrdinalIgnoreCase)
- .ThenBy(entry => entry.Line, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(item => item.PackageId.Id, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(item => item.PackageId.ManagerName, StringComparer.OrdinalIgnoreCase)
+ .ThenByDescending(item => item.Entry.Severity)
+ .ThenBy(item => item.Entry.Line, StringComparer.OrdinalIgnoreCase)
+ .Select(item => new IpcBundleSecurityEntry
+ {
+ PackageId = item.PackageId.Id,
+ ManagerName = item.PackageId.ManagerName,
+ Source = item.PackageId.Source,
+ Severity = item.Entry.Severity.ToString().ToLowerInvariant(),
+ Field = item.Entry.Field,
+ Label = item.Entry.Label,
+ Value = item.Entry.Value,
+ Line = item.Entry.Line,
+ Allowed = item.Entry.Allowed,
+ })
.ToArray();
}
}
diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/BundleImportFilter.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/BundleImportFilter.cs
index 00b377c5eb..86bec5459c 100644
--- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/BundleImportFilter.cs
+++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/BundleImportFilter.cs
@@ -1,6 +1,9 @@
+using System.Text;
+using UniGetUI.Core.Logging;
using UniGetUI.Core.SettingsEngine.SecureSettings;
using UniGetUI.Core.Tools;
using UniGetUI.Interface.Enums;
+using UniGetUI.PackageEngine.Interfaces;
using UniGetUI.PackageEngine.Serializable;
namespace UniGetUI.PackageEngine.Classes.Manager.Classes;
@@ -17,74 +20,85 @@ public static bool PrePostCommandsAllowed() =>
public static InstallOptions Apply(
ref BundleReport report,
- string packageId,
+ BundleReportSubject subject,
InstallOptions options,
bool allowCliArguments,
bool allowPrePostCommands,
- bool commandLineIsShellInterpreted
+ bool commandLineIsShellInterpreted,
+ string sourceName = "",
+ BundleSourceStatus sourceStatus = BundleSourceStatus.Default
)
{
ReportList(
ref report,
- packageId,
+ subject,
options.CustomParameters_Install,
+ nameof(options.CustomParameters_Install),
"Custom install arguments",
allowCliArguments
);
ReportList(
ref report,
- packageId,
+ subject,
options.CustomParameters_Update,
+ nameof(options.CustomParameters_Update),
"Custom update arguments",
allowCliArguments
);
ReportList(
ref report,
- packageId,
+ subject,
options.CustomParameters_Uninstall,
+ nameof(options.CustomParameters_Uninstall),
"Custom uninstall arguments",
allowCliArguments
);
options.PreInstallCommand = ReportString(
ref report,
- packageId,
+ subject,
options.PreInstallCommand,
+ nameof(options.PreInstallCommand),
"Pre-install command",
allowPrePostCommands
);
options.PostInstallCommand = ReportString(
ref report,
- packageId,
+ subject,
options.PostInstallCommand,
+ nameof(options.PostInstallCommand),
"Post-install command",
allowPrePostCommands
);
options.PreUpdateCommand = ReportString(
ref report,
- packageId,
+ subject,
options.PreUpdateCommand,
+ nameof(options.PreUpdateCommand),
"Pre-update command",
allowPrePostCommands
);
options.PostUpdateCommand = ReportString(
ref report,
- packageId,
+ subject,
options.PostUpdateCommand,
+ nameof(options.PostUpdateCommand),
"Post-update command",
allowPrePostCommands
);
options.PreUninstallCommand = ReportString(
ref report,
- packageId,
+ subject,
options.PreUninstallCommand,
+ nameof(options.PreUninstallCommand),
"Pre-uninstall command",
allowPrePostCommands
);
options.PostUninstallCommand = ReportString(
ref report,
- packageId,
+ subject,
options.PostUninstallCommand,
+ nameof(options.PostUninstallCommand),
"Post-uninstall command",
allowPrePostCommands
);
@@ -95,17 +109,92 @@ bool commandLineIsShellInterpreted
if (commandLineIsShellInterpreted)
options.Version = ReportOutOfPatternValue(
ref report,
- packageId,
+ subject,
options.Version,
+ nameof(options.Version),
"Requested version"
);
+
+ ReportFlag(
+ ref report,
+ subject,
+ options.SkipHashCheck,
+ nameof(options.SkipHashCheck),
+ "Installer integrity check disabled",
+ BundleReportSeverity.High
+ );
+ ReportFlag(
+ ref report,
+ subject,
+ options.RunAsAdministrator,
+ nameof(options.RunAsAdministrator),
+ "Runs elevated",
+ BundleReportSeverity.Info
+ );
+ ReportInformativeList(
+ ref report,
+ subject,
+ options.KillBeforeOperation,
+ nameof(options.KillBeforeOperation),
+ "Processes terminated before the operation",
+ BundleReportSeverity.High
+ );
+ ReportInformativeString(
+ ref report,
+ subject,
+ options.CustomInstallLocation,
+ nameof(options.CustomInstallLocation),
+ "Custom install location",
+ BundleReportSeverity.Info
+ );
+
+ if (sourceStatus is not BundleSourceStatus.Default)
+ ReportInformativeString(
+ ref report,
+ subject,
+ sourceName,
+ "Source",
+ sourceStatus is BundleSourceStatus.Unknown
+ ? "Unknown package source"
+ : "Non-default package source",
+ BundleReportSeverity.Info
+ );
+
return options;
}
+ public static (string Name, BundleSourceStatus Status) ClassifySource(
+ IPackageManager? manager,
+ string declaredSource
+ )
+ {
+ string name = declaredSource.Contains(": ")
+ ? declaredSource.Split(": ")[^1]
+ : declaredSource;
+
+ if (manager is null || name.Length is 0 || !manager.Capabilities.SupportsCustomSources)
+ return (name, BundleSourceStatus.Default);
+
+ if (manager.DefaultSource.Name == name)
+ return (name, BundleSourceStatus.Default);
+
+ var factory = manager.SourcesHelper?.Factory;
+ if (factory is null || factory.GetAvailableSources().Length is 0)
+ return (name, BundleSourceStatus.Default);
+
+ return (
+ name,
+ factory.GetSourceIfExists(name) is not null
+ ? BundleSourceStatus.Known
+ : BundleSourceStatus.Unknown
+ );
+ }
+
private static void ReportList(
ref BundleReport report,
- string packageId,
+ BundleReportSubject subject,
List values,
+ string field,
string label,
bool allowed
)
@@ -113,7 +202,18 @@ bool allowed
if (!values.Any(value => value.Any()))
return;
- Add(ref report, packageId, $"{label}: [{string.Join(", ", values)}]", allowed);
+ string value = string.Join(", ", values);
+ Add(
+ ref report,
+ subject,
+ field,
+ label,
+ value,
+ $"{label}: [{value}]",
+ BundleReportSeverity.High,
+ allowed,
+ !allowed
+ );
if (!allowed)
values.Clear();
@@ -121,8 +221,9 @@ bool allowed
private static string ReportString(
ref BundleReport report,
- string packageId,
+ BundleReportSubject subject,
string value,
+ string field,
string label,
bool allowed
)
@@ -130,33 +231,157 @@ bool allowed
if (!value.Any())
return value;
- Add(ref report, packageId, $"{label}: {value}", allowed);
+ Add(
+ ref report,
+ subject,
+ field,
+ label,
+ value,
+ $"{label}: {value}",
+ BundleReportSeverity.High,
+ allowed,
+ !allowed
+ );
return allowed ? value : "";
}
private static string ReportOutOfPatternValue(
ref BundleReport report,
- string packageId,
+ BundleReportSubject subject,
string value,
+ string field,
string label
)
{
if (value.Length is 0 || CoreTools.IsCommandLineInertValue(value))
return value;
- Add(ref report, packageId, $"{label}: {value}", false);
+ Add(
+ ref report,
+ subject,
+ field,
+ label,
+ value,
+ $"{label}: {value}",
+ BundleReportSeverity.High,
+ false
+ );
return "";
}
- private static void Add(ref BundleReport report, string packageId, string line, bool allowed)
+ private static void ReportFlag(
+ ref BundleReport report,
+ BundleReportSubject subject,
+ bool value,
+ string field,
+ string label,
+ BundleReportSeverity severity
+ )
+ {
+ if (!value)
+ return;
+
+ Add(ref report, subject, field, label, "true", label, severity, true);
+ }
+
+ private static void ReportInformativeString(
+ ref BundleReport report,
+ BundleReportSubject subject,
+ string value,
+ string field,
+ string label,
+ BundleReportSeverity severity
+ )
+ {
+ if (!value.Any())
+ return;
+
+ Add(ref report, subject, field, label, value, $"{label}: {value}", severity, true);
+ }
+
+ private static void ReportInformativeList(
+ ref BundleReport report,
+ BundleReportSubject subject,
+ List values,
+ string field,
+ string label,
+ BundleReportSeverity severity
+ )
{
- if (!report.Contents.TryGetValue(packageId, out var entries))
+ if (!values.Any(value => value.Any()))
+ return;
+
+ string value = string.Join(", ", values);
+ Add(ref report, subject, field, label, value, $"{label}: [{value}]", severity, true);
+ }
+
+ private static void Add(
+ ref BundleReport report,
+ BundleReportSubject subject,
+ string field,
+ string label,
+ string value,
+ string line,
+ BundleReportSeverity severity,
+ bool allowed,
+ bool strippedBySetting = false
+ )
+ {
+ if (!report.Contents.TryGetValue(subject.Key, out var package))
{
- entries = [];
- report.Contents[packageId] = entries;
+ package = new BundleReportPackage(subject);
+ report.Contents[subject.Key] = package;
}
- entries.Add(new BundleReportEntry(line, allowed));
+ package.Entries.Add(
+ new BundleReportEntry(
+ field,
+ label,
+ value,
+ line,
+ severity,
+ allowed,
+ strippedBySetting
+ )
+ );
report.IsEmpty = false;
}
+
+ public static void LogReport(BundleReport report, string source)
+ {
+ if (report.IsEmpty)
+ return;
+
+ Logger.Warn(
+ $"Bundle \"{Sanitize(source)}\" carries {report.HighSeverityCount} high-severity "
+ + $"and {report.InformationalCount} informational security findings"
+ );
+
+ foreach (var package in report.Contents.Values)
+ foreach (var entry in package.Entries)
+ Logger.Warn(
+ $" [{entry.Severity}] {Sanitize(package.Subject.Id)} "
+ + $"({Sanitize(package.Subject.ManagerName)}): {entry.Label}"
+ + (entry.Allowed ? "" : " -- stripped on import")
+ );
+ }
+
+ private const int MaxLoggedLength = 120;
+
+ private static string Sanitize(string value)
+ {
+ if (value.Length is 0)
+ return value;
+
+ var builder = new StringBuilder(Math.Min(value.Length, MaxLoggedLength));
+ foreach (char character in value)
+ {
+ if (builder.Length >= MaxLoggedLength)
+ return builder.Append("...").ToString();
+
+ builder.Append(char.IsControl(character) ? ' ' : character);
+ }
+
+ return builder.ToString();
+ }
}
diff --git a/src/UniGetUI.PackageEngine.Tests/BundleImportFilterTests.cs b/src/UniGetUI.PackageEngine.Tests/BundleImportFilterTests.cs
index 790cd408a6..602f481cbd 100644
--- a/src/UniGetUI.PackageEngine.Tests/BundleImportFilterTests.cs
+++ b/src/UniGetUI.PackageEngine.Tests/BundleImportFilterTests.cs
@@ -10,23 +10,27 @@ private static (InstallOptions Options, BundleReport Report) Filter(
InstallOptions options,
bool allowCli = true,
bool allowPrePost = true,
- bool shellInterpreted = true
+ bool shellInterpreted = true,
+ string sourceName = "",
+ BundleSourceStatus sourceStatus = BundleSourceStatus.Default
)
{
var report = new BundleReport { IsEmpty = true };
var filtered = BundleImportFilter.Apply(
ref report,
- "Contoso.Test",
+ new BundleReportSubject("Contoso.Test", "Contoso Test", "Winget", "winget"),
options,
allowCli,
allowPrePost,
- shellInterpreted
+ shellInterpreted,
+ sourceName,
+ sourceStatus
);
return (filtered, report);
}
private static IEnumerable EntriesFor(BundleReport report) =>
- report.Contents.TryGetValue("Contoso.Test", out var entries) ? entries : [];
+ report.Contents.Values.SelectMany(package => package.Entries);
[Fact]
public void ALegitimateVersionIsKeptAndDoesNotTriggerTheReport()
@@ -148,4 +152,297 @@ public void EveryStrippedFieldIsReportedIndividually()
Assert.Equal(3, EntriesFor(report).Count());
Assert.All(EntriesFor(report), entry => Assert.False(entry.Allowed));
}
+
+ [Fact]
+ public void SkipHashCheckIsReportedAsHighSeverityWithoutBeingStripped()
+ {
+ var (options, report) = Filter(new InstallOptions { SkipHashCheck = true });
+
+ Assert.True(options.SkipHashCheck);
+ var entry = Assert.Single(EntriesFor(report));
+ Assert.Equal(nameof(InstallOptions.SkipHashCheck), entry.Field);
+ Assert.Equal("true", entry.Value);
+ Assert.Equal(entry.Label, entry.Line);
+ Assert.False(entry.LineCarriesTheValue);
+ Assert.Equal(BundleReportSeverity.High, entry.Severity);
+ Assert.True(entry.Allowed);
+ Assert.True(report.HasHighSeverityFindings);
+ }
+
+ [Fact]
+ public void RunAsAdministratorIsReportedButDoesNotGateTheImport()
+ {
+ var (options, report) = Filter(new InstallOptions { RunAsAdministrator = true });
+
+ Assert.True(options.RunAsAdministrator);
+ var entry = Assert.Single(EntriesFor(report));
+ Assert.Equal(nameof(InstallOptions.RunAsAdministrator), entry.Field);
+ Assert.Equal(BundleReportSeverity.Info, entry.Severity);
+ Assert.False(report.HasHighSeverityFindings);
+ }
+
+ [Fact]
+ public void KillBeforeOperationIsReportedAsHighSeverityWithEveryProcessNamed()
+ {
+ var (options, report) = Filter(
+ new InstallOptions { KillBeforeOperation = ["explorer", "msedge"] }
+ );
+
+ Assert.Equal(["explorer", "msedge"], options.KillBeforeOperation);
+ var entry = Assert.Single(EntriesFor(report));
+ Assert.Equal(nameof(InstallOptions.KillBeforeOperation), entry.Field);
+ Assert.Equal("explorer, msedge", entry.Value);
+ Assert.Equal(BundleReportSeverity.High, entry.Severity);
+ }
+
+ [Fact]
+ public void ACustomInstallLocationIsReportedAsInformational()
+ {
+ var (options, report) = Filter(
+ new InstallOptions { CustomInstallLocation = @"C:\Windows\System32" }
+ );
+
+ Assert.Equal(@"C:\Windows\System32", options.CustomInstallLocation);
+ var entry = Assert.Single(EntriesFor(report));
+ Assert.Equal(nameof(InstallOptions.CustomInstallLocation), entry.Field);
+ Assert.Equal("Custom install location", entry.Label);
+ Assert.Equal(@"C:\Windows\System32", entry.Value);
+ Assert.True(entry.LineCarriesTheValue);
+ Assert.Equal(BundleReportSeverity.Info, entry.Severity);
+ Assert.False(report.HasHighSeverityFindings);
+ }
+
+ [Fact]
+ public void ADefaultSourceIsNotReported()
+ {
+ var (_, report) = Filter(new InstallOptions(), sourceName: "winget");
+
+ Assert.True(report.IsEmpty);
+ }
+
+ [Fact]
+ public void AKnownNonDefaultSourceIsReportedAsInformational()
+ {
+ var (_, report) = Filter(
+ new InstallOptions(),
+ sourceName: "msstore",
+ sourceStatus: BundleSourceStatus.Known
+ );
+
+ var entry = Assert.Single(EntriesFor(report));
+ Assert.Equal("Source", entry.Field);
+ Assert.Equal("msstore", entry.Value);
+ Assert.Equal(BundleReportSeverity.Info, entry.Severity);
+ Assert.False(report.HasHighSeverityFindings);
+ }
+
+ [Fact]
+ public void ASourceTheManagerDoesNotKnowIsReportedButDoesNotGateTheImport()
+ {
+ var (_, report) = Filter(
+ new InstallOptions(),
+ sourceName: "http://evil.example/feed",
+ sourceStatus: BundleSourceStatus.Unknown
+ );
+
+ var entry = Assert.Single(EntriesFor(report));
+ Assert.Equal("Source", entry.Field);
+ Assert.Equal("Unknown package source", entry.Label);
+ Assert.Equal(BundleReportSeverity.Info, entry.Severity);
+ Assert.False(report.HasHighSeverityFindings);
+ }
+
+ [Fact]
+ public void TheAlreadyExistingChecksCarryAHighSeverity()
+ {
+ var (_, report) = Filter(
+ new InstallOptions
+ {
+ Version = "1.0; calc",
+ PreInstallCommand = "calc",
+ CustomParameters_Install = ["--evil"],
+ },
+ allowCli: false,
+ allowPrePost: false
+ );
+
+ Assert.All(
+ EntriesFor(report),
+ entry => Assert.Equal(BundleReportSeverity.High, entry.Severity)
+ );
+ Assert.Equal(
+ [
+ nameof(InstallOptions.CustomParameters_Install),
+ nameof(InstallOptions.PreInstallCommand),
+ nameof(InstallOptions.Version),
+ ],
+ EntriesFor(report).Select(entry => entry.Field).Order()
+ );
+ Assert.True(report.HasHighSeverityFindings);
+ }
+
+ [Fact]
+ public void AnInformationalOnlyBundleDoesNotTriggerTheImportGate()
+ {
+ var (_, report) = Filter(
+ new InstallOptions { CustomInstallLocation = "D:\\Apps" },
+ sourceName: "msstore",
+ sourceStatus: BundleSourceStatus.Known
+ );
+
+ Assert.Equal(2, EntriesFor(report).Count());
+ Assert.False(report.HasHighSeverityFindings);
+ }
+
+ [Fact]
+ public void TheReportCarriesThePackageNameAndManagerForDisplay()
+ {
+ var (_, report) = Filter(new InstallOptions { RunAsAdministrator = true });
+
+ var package = Assert.Single(report.Contents).Value;
+ Assert.Equal("Contoso.Test", package.Subject.Id);
+ Assert.Equal("Contoso Test", package.Subject.Name);
+ Assert.Equal("Winget", package.Subject.ManagerName);
+ Assert.Equal("winget", package.Subject.Source);
+ Assert.Equal("Contoso Test", package.Subject.DisplayName);
+ }
+
+ [Fact]
+ public void ASubjectWithoutANameFallsBackToItsIdForDisplay()
+ {
+ Assert.Equal(
+ "Contoso.Test",
+ new BundleReportSubject("Contoso.Test", "", "Winget", "winget").DisplayName);
+ }
+
+ [Fact]
+ public void TheReportCountsFindingsBySeverityAndTracksStrippedOnes()
+ {
+ var (_, report) = Filter(
+ new InstallOptions
+ {
+ RunAsAdministrator = true,
+ PreInstallCommand = "calc",
+ CustomInstallLocation = @"D:\Apps",
+ },
+ allowPrePost: false
+ );
+
+ Assert.Equal(1, report.HighSeverityCount);
+ Assert.Equal(2, report.InformationalCount);
+ Assert.True(report.HasStrippedFindings);
+ Assert.Equal(3, report.AllEntries.Count());
+ }
+
+ [Fact]
+ public void AReportWithNothingStrippedSaysSo()
+ {
+ var (_, report) = Filter(new InstallOptions { SkipHashCheck = true });
+
+ Assert.False(report.HasStrippedFindings);
+ }
+
+ [Fact]
+ public void APackageIsFlaggedRiskyOnlyWhenItCarriesAHighSeverityFinding()
+ {
+ var (_, risky) = Filter(new InstallOptions { SkipHashCheck = true });
+ var (_, tame) = Filter(new InstallOptions { RunAsAdministrator = true });
+
+ Assert.True(Assert.Single(risky.Contents).Value.HasHighSeverityFindings);
+ Assert.False(Assert.Single(tame.Contents).Value.HasHighSeverityFindings);
+ }
+
+ [Fact]
+ public void TheSamePackageIdFromTwoManagersIsKeptApart()
+ {
+ var report = new BundleReport { IsEmpty = true };
+ BundleImportFilter.Apply(
+ ref report,
+ new BundleReportSubject("nodejs", "Node.js", "Scoop", "main"),
+ new InstallOptions { RunAsAdministrator = true },
+ true, true, false);
+ BundleImportFilter.Apply(
+ ref report,
+ new BundleReportSubject("nodejs", "Node.js", "Chocolatey", "chocolatey"),
+ new InstallOptions { SkipHashCheck = true },
+ true, true, false);
+
+ Assert.Equal(2, report.Contents.Count);
+ Assert.Equal(
+ ["Chocolatey", "Scoop"],
+ report.Contents.Values.Select(entry => entry.Subject.ManagerName).Order());
+ }
+
+ [Fact]
+ public void AValueStrippedBySecureSettingsIsMarkedAsReEnableable()
+ {
+ var (_, report) = Filter(
+ new InstallOptions { PreInstallCommand = "calc" },
+ allowPrePost: false
+ );
+
+ Assert.True(Assert.Single(EntriesFor(report)).StrippedBySetting);
+ Assert.True(report.HasSettingControlledStripping);
+ }
+
+ [Fact]
+ public void AVersionStrippedByPatternIsNotPresentedAsReEnableable()
+ {
+ var (_, report) = Filter(new InstallOptions { Version = "1.0; calc" });
+
+ var entry = Assert.Single(EntriesFor(report));
+ Assert.False(entry.Allowed);
+ Assert.False(entry.StrippedBySetting);
+ Assert.True(report.HasStrippedFindings);
+ Assert.False(report.HasSettingControlledStripping);
+ }
+
+ [Fact]
+ public void OnlyTheFindingsThatSurviveAsHighSeverityGateTheImport()
+ {
+ var (_, report) = Filter(
+ new InstallOptions
+ {
+ RunAsAdministrator = true,
+ CustomInstallLocation = @"D:\Apps",
+ },
+ sourceName: "http://evil.example/feed",
+ sourceStatus: BundleSourceStatus.Unknown
+ );
+
+ Assert.Equal(3, report.InformationalCount);
+ Assert.Equal(0, report.HighSeverityCount);
+ Assert.False(report.HasHighSeverityFindings);
+ }
+
+ [Fact]
+ public void SkipHashCheckAndProcessKillsStillGateTheImport()
+ {
+ var (_, hash) = Filter(new InstallOptions { SkipHashCheck = true });
+ var (_, kills) = Filter(new InstallOptions { KillBeforeOperation = ["explorer"] });
+
+ Assert.True(hash.HasHighSeverityFindings);
+ Assert.True(kills.HasHighSeverityFindings);
+ }
+
+ [Fact]
+ public void TheSamePackageIdFromTwoSourcesOfOneManagerIsKeptApart()
+ {
+ var report = new BundleReport { IsEmpty = true };
+ BundleImportFilter.Apply(
+ ref report,
+ new BundleReportSubject("Contoso.App", "App", "Winget", "winget"),
+ new InstallOptions { SkipHashCheck = true },
+ true, true, false);
+ BundleImportFilter.Apply(
+ ref report,
+ new BundleReportSubject("Contoso.App", "App", "Winget", "msstore"),
+ new InstallOptions { RunAsAdministrator = true },
+ true, true, false);
+
+ Assert.Equal(2, report.Contents.Count);
+ Assert.Equal(
+ ["msstore", "winget"],
+ report.Contents.Values.Select(package => package.Subject.Source).Order());
+ }
}