Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.48.23] - 2026-05-15

### Fixed
- **UpdateService** — added Authenticode signature verification on downloaded
update binaries; rejects files with invalid (tampered) signatures (SEC-H1).
- **AboutViewModel** — update script now uses a random GUID filename to prevent
TOCTOU race conditions on the updater .cmd file (SEC-M1).
- **UninstallerService** — uninstall executables from registry are now validated
against trusted directories (Program Files, Windows, ProgramData,
LocalApplicationData); rejects paths outside these locations (SEC-H2).
- **EventLogService** — XPath sanitization now strips all metacharacters
including `|()@*<>` in addition to the existing set (SEC-M5).
- **BatteryInfo** — `HealthPercent` returns -1 (unavailable) instead of 0 when
WMI capacity data is missing (no admin elevation), preventing false-critical
health scores on every non-elevated laptop (FUNC-H1).
- **HealthScoreService** — `ComputeBatteryScore` treats -1 (unavailable) as
neutral (100) instead of critical (10).
- **BatteryHealthViewModel** — displays "requires elevation" when health data
is unavailable instead of showing 0%.
- **StartupService** — registry approved-state blob now uses bitmask
`(blob[0] & 1) == 0` for enabled detection, fixing Windows 11 which uses
`07` (not just `03`) for disabled entries (FUNC-M1).

## [0.48.22] - 2026-05-15

### Fixed
Expand Down
8 changes: 4 additions & 4 deletions SysManager/SysManager.Tests/BatteryInfoEdgeCaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ namespace SysManager.Tests;
public class BatteryInfoEdgeCaseTests
{
[Fact]
public void HealthPercent_ZeroDesignCapacity_ReturnsZero()
public void HealthPercent_ZeroDesignCapacity_ReturnsUnavailable()
{
var info = new BatteryInfo { DesignCapacityMWh = 0, FullChargeCapacityMWh = 5000 };
Assert.Equal(0, info.HealthPercent);
Assert.Equal(-1, info.HealthPercent);
}

[Fact]
Expand All @@ -35,10 +35,10 @@ public void HealthPercent_NewBatteryOverDesign_ClampedTo100()
}

[Fact]
public void WearPercent_ZeroDesignCapacity_ReturnsZero()
public void WearPercent_ZeroDesignCapacity_ReturnsUnavailable()
{
var info = new BatteryInfo { DesignCapacityMWh = 0, FullChargeCapacityMWh = 5000 };
Assert.Equal(0, info.WearPercent);
Assert.Equal(-1, info.WearPercent);
}

[Fact]
Expand Down
8 changes: 4 additions & 4 deletions SysManager/SysManager.Tests/BatteryServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ public void BatteryInfo_HealthPercent_CalculatesCorrectly()
}

[Fact]
public void BatteryInfo_HealthPercent_ZeroDesign_ReturnsZero()
public void BatteryInfo_HealthPercent_ZeroDesign_ReturnsUnavailable()
{
var info = new BatteryInfo { DesignCapacityMWh = 0, FullChargeCapacityMWh = 1000 };
Assert.Equal(0, info.HealthPercent);
Assert.Equal(-1, info.HealthPercent);
}

[Fact]
Expand All @@ -84,10 +84,10 @@ public void BatteryInfo_WearPercent_CalculatesCorrectly()
}

[Fact]
public void BatteryInfo_WearPercent_ZeroDesign_ReturnsZero()
public void BatteryInfo_WearPercent_ZeroDesign_ReturnsUnavailable()
{
var info = new BatteryInfo { DesignCapacityMWh = 0 };
Assert.Equal(0, info.WearPercent);
Assert.Equal(-1, info.WearPercent);
}

[Fact]
Expand Down
16 changes: 10 additions & 6 deletions SysManager/SysManager/Models/BatteryInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,21 @@ public partial class BatteryInfo : ObservableObject
[ObservableProperty] private string _chemistry = ""; // LiIon, NiMH, etc.
[ObservableProperty] private string _manufacturer = "";

/// <summary>Health percentage: FullCharge / Design × 100.</summary>
/// <summary>
/// Health percentage: FullCharge / Design × 100.
/// Returns -1 when capacity data is unavailable (e.g. no admin elevation
/// for root\WMI queries) to avoid false-critical health scores.
/// </summary>
public double HealthPercent =>
DesignCapacityMWh > 0
DesignCapacityMWh > 0 && FullChargeCapacityMWh > 0
? Math.Min(Math.Round(FullChargeCapacityMWh * 100.0 / DesignCapacityMWh, 1), 100)
: 0;
: -1;

/// <summary>Wear level: 100 − HealthPercent.</summary>
/// <summary>Wear level: 100 − HealthPercent. Returns -1 when data unavailable.</summary>
public double WearPercent =>
DesignCapacityMWh > 0
HealthPercent >= 0
? Math.Max(Math.Round(100.0 - HealthPercent, 1), 0)
: 0;
: -1;

/// <summary>Formatted estimated runtime.</summary>
public string RuntimeDisplay => EstimatedRuntimeMinutes switch
Expand Down
9 changes: 8 additions & 1 deletion SysManager/SysManager/Services/EventLogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,14 @@ private static string BuildXPath(EventLogQueryOptions opt)
.Replace("[", "")
.Replace("]", "")
.Replace("/", "")
.Replace("\\", "");
.Replace("\\", "")
.Replace("|", "")
.Replace("(", "")
.Replace(")", "")
.Replace("@", "")
.Replace("*", "")
.Replace("<", "")
.Replace(">", "");
clauses.Add($"Provider[@Name='{safe}']");
}

Expand Down
4 changes: 4 additions & 0 deletions SysManager/SysManager/Services/HealthScoreService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ internal static int ComputeBatteryScore(BatteryInfo? battery)
if (battery == null || !battery.HasBattery) return 100;

double health = battery.HealthPercent;
// -1 means capacity data unavailable (no admin for root\WMI).
// Return neutral score to avoid false-critical warnings.
if (health < 0) return 100;

return health switch
{
>= 80 => 100,
Expand Down
5 changes: 4 additions & 1 deletion SysManager/SysManager/Services/StartupService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,10 @@ private static void ApplyApprovedState(List<StartupEntry> entries)

if (approved != null && approved.TryGetValue(entry.ValueName, out var blob) && blob.Length >= 1)
{
entry.IsEnabled = blob[0] != 3;
// Windows uses bit 0 to indicate disabled state:
// 02/06 = enabled (even), 03/07 = disabled (odd).
// Windows 11 uses 07 in addition to the classic 03.
entry.IsEnabled = (blob[0] & 1) == 0;
entry.StatusText = entry.IsEnabled ? "Enabled" : "Disabled";
}
}
Expand Down
22 changes: 22 additions & 0 deletions SysManager/SysManager/Services/UninstallerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,28 @@
if (!ext.Equals(".exe", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Uninstall target is not an executable (.exe): '{exe}'. Refusing to run for security.");

// SEC-H2: Validate the executable resides under a trusted directory.
// Registry uninstall keys (especially HKCU) can be modified without admin,
// so we must not execute arbitrary paths. Allow: Program Files, Windows,
// ProgramData, and LocalApplicationData (per-user installs like VS Code).
var fullPath = System.IO.Path.GetFullPath(exe);
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var pfx86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
var winDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
var progData = Environment.GetEnvironmentVariable("ProgramData") ?? @"C:\ProgramData";
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);

var isTrustedPath =
(!string.IsNullOrEmpty(pf) && fullPath.StartsWith(pf, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrEmpty(pfx86) && fullPath.StartsWith(pfx86, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrEmpty(winDir) && fullPath.StartsWith(winDir, StringComparison.OrdinalIgnoreCase)) ||
fullPath.StartsWith(progData, StringComparison.OrdinalIgnoreCase) ||
(!string.IsNullOrEmpty(localAppData) && fullPath.StartsWith(localAppData, StringComparison.OrdinalIgnoreCase));

Check notice

Code scanning / CodeQL

Complex condition Note

Complex condition: too many logical operations in this expression.
Comment on lines +297 to +301

if (!isTrustedPath)
throw new InvalidOperationException(
$"Uninstall executable is outside trusted directories: '{exe}'. Refusing to run for security.");
}

Log.Information("Uninstalling local app '{Name}' via: {Exe} {Args}", app.Name, exe, args);
Expand Down
30 changes: 30 additions & 0 deletions SysManager/SysManager/Services/UpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,36 @@
}
}

/// <summary>
/// Verifies the Authenticode signature on a downloaded update binary.
/// Returns true if the binary is signed or unsigned (open-source builds).
/// Returns false only if the signature is present but INVALID (tampered).
/// </summary>
public static bool VerifyAuthenticode(string filePath)
{
try
{
#pragma warning disable SYSLIB0057 // CreateFromSignedFile is obsolete
var cert = System.Security.Cryptography.X509Certificates.X509Certificate
.CreateFromSignedFile(filePath);

Check warning

Code scanning / CodeQL

Call to obsolete method Warning

Call to obsolete method
CreateFromSignedFile
.
Comment thread
laurentiu021 marked this conversation as resolved.
Dismissed
#pragma warning restore SYSLIB0057
if (cert != null)
{
Serilog.Log.Information("Update binary Authenticode verified: {Subject}", cert.Subject);
return true;
}
// Unsigned binary — acceptable for open-source GitHub Actions builds.
Serilog.Log.Information("Update binary has no Authenticode signature (expected for unsigned builds)");
return true;
}
catch (System.Security.Cryptography.CryptographicException)
{
// Invalid/tampered signature — file HAS a signature but it's broken.
Serilog.Log.Warning("Update binary has INVALID Authenticode signature — possible tampering: {File}", filePath);
return false;
}
}

// ---------- internals ----------

private static ReleaseInfo? Map(GhRelease dto)
Expand Down
9 changes: 8 additions & 1 deletion SysManager/SysManager/ViewModels/AboutViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,13 @@ private async Task InstallUpdateAsync()
return;
}

// Step 1b: Verify Authenticode signature (detects tampered binaries).
if (!UpdateService.VerifyAuthenticode(DownloadedPath))
{
DownloadStatus = "Update binary has an invalid digital signature — possible tampering. Download aborted.";
return;
}

// Step 2: Determine current executable path.
var currentExe = Environment.ProcessPath;
if (string.IsNullOrWhiteSpace(currentExe) || !File.Exists(currentExe))
Expand All @@ -421,7 +428,7 @@ private async Task InstallUpdateAsync()
var pid = Environment.ProcessId;
var scriptPath = Path.Combine(
Path.GetDirectoryName(DownloadedPath)!,
"update.cmd");
$"update-{Guid.NewGuid():N}.cmd");

var script = $"""
@echo off
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ private async Task RefreshAsync()
Battery = await _service.GetBatteryInfoAsync();

Summary = Battery.HasBattery
? $"{Battery.Name} · {Battery.ChargePercent}% · Health {Battery.HealthPercent}% · {Battery.Status}"
? Battery.HealthPercent >= 0
? $"{Battery.Name} · {Battery.ChargePercent}% · Health {Battery.HealthPercent}% · {Battery.Status}"
: $"{Battery.Name} · {Battery.ChargePercent}% · Health: requires elevation · {Battery.Status}"
: "No battery detected — this device runs on AC power only.";

StatusMessage = Battery.HasBattery
Expand Down
Loading