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
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,44 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.48.24] - 2026-05-15

### Fixed
- **UpdateService** — cached download now validated by SHA-256 hash (stored in
companion `.sha256` file) instead of file size alone; prevents cache poisoning
with same-size payloads (SEC-M2).
- **SpeedTestService** — Zip Slip protection: manual extraction validates each
entry path stays within the target directory; blocks path traversal attacks
via crafted zip archives (SEC-M3).
- **SpeedTestService** — DLL hijacking mitigation: Ookla CLI process now
launches with `WorkingDirectory` set to System32 instead of the user-writable
tools directory, preventing CWD-based DLL search order hijacking (SEC-M4).
- **ServiceManagerService** — defensive validation on service names before
interpolating into registry paths; rejects names containing path separators
or null characters (SEC-M6).
- **UninstallerService** — `ParseUninstallCommand` hardened: rejects shell
metacharacters (`|&;` backtick `$(`) to prevent command injection; improved
`.exe` boundary detection to avoid misparsing paths with `.exe` substrings;
removed unsafe fallback that treated unparseable strings as executables
(SEC-M7).
- **PowerShellRunner** — expanded security contract documentation clarifying
that `ExecutionPolicy.Bypass` is safe only because all script content is
hard-coded in source; callers must never interpolate user input (SEC-M8).
- **DiskHealthService** — replaced bare `catch` blocks in WMI conversion
helpers with specific exception types (`FormatException`, `OverflowException`,
`InvalidCastException`).
- **DeepCleanupService** — replaced bare `catch` with specific `IOException`,
`UnauthorizedAccessException`, `SecurityException`.
- **TracerouteMonitorService** — replaced bare `catch` with specific network
and operation exception types.
- **TracerouteService** — replaced generic `catch (Exception)` in event raiser
with specific `ObjectDisposedException`, `InvalidOperationException`.
- **PingMonitorService** — replaced bare `catch` in event raiser with specific
exception types.
- **EventLogService** — replaced bare `catch` blocks in record projection and
message formatting with specific `EventLogException`,
`InvalidOperationException`.

## [0.48.23] - 2026-05-15

### Fixed
Expand Down
31 changes: 27 additions & 4 deletions SysManager/SysManager.Tests/UninstallerServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -270,11 +270,11 @@ public void ParseUninstallCommand_UnquotedExePath_FindsExeBoundary()
}

[Fact]
public void ParseUninstallCommand_NoExeExtension_FallsBackToFullString()
public void ParseUninstallCommand_NoExeExtension_ThrowsInvalidOperation()
{
var (exe, args) = UninstallerService.ParseUninstallCommand("some-command");
Assert.Equal("some-command", exe);
Assert.Equal("", args);
// SEC-M7: Commands without a valid .exe boundary are rejected for security.
Assert.Throws<InvalidOperationException>(
() => UninstallerService.ParseUninstallCommand("some-command"));
}

[Fact]
Expand All @@ -286,4 +286,27 @@ public void ParseUninstallCommand_MsiExecWithQn_DoesNotAddQuiet()
Assert.DoesNotContain("/quiet", args);
Assert.Contains("/qn", args);
}

[Theory]
[InlineData("cmd.exe /c calc.exe | evil.exe")]
[InlineData("uninstall.exe & del /q *")]
[InlineData("app.exe; rm -rf /")]
[InlineData("tool.exe `whoami`")]
[InlineData("app.exe $(malicious)")]
public void ParseUninstallCommand_ShellMetacharacters_Throws(string command)
{
// SEC-M7: Shell metacharacters are rejected to prevent command injection.
Assert.Throws<InvalidOperationException>(
() => UninstallerService.ParseUninstallCommand(command));
}

[Fact]
public void ParseUninstallCommand_ExeInMiddleOfPath_FindsCorrectBoundary()
{
// SEC-M7: ".exe" followed by non-boundary char should not split there.
var (exe, args) = UninstallerService.ParseUninstallCommand(
@"C:\dir\app.exefiles\tool.exe /silent");
Assert.Equal(@"C:\dir\app.exefiles\tool.exe", exe);
Assert.Equal("/silent", args);
}
}
4 changes: 3 additions & 1 deletion SysManager/SysManager/Services/DeepCleanupService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,9 @@ private static IReadOnlyList<CleanupCategory> Scan(IProgress<ScanProgress>? prog
}
files++;
}
catch { }
catch (IOException) { /* file locked or inaccessible — skip */ }
catch (UnauthorizedAccessException) { /* no permission — skip */ }
catch (System.Security.SecurityException) { /* restricted — skip */ }
}
}

Expand Down
15 changes: 12 additions & 3 deletions SysManager/SysManager/Services/DiskHealthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,19 +160,28 @@ private static void ApplyVerdict(DiskHealthReport r)
private static double? ToDouble(object? o)
{
if (o == null) return null;
try { var v = Convert.ToDouble(o); return Math.Abs(v) < 1e-9 ? null : v; } catch { return null; }
try { var v = Convert.ToDouble(o); return Math.Abs(v) < 1e-9 ? null : v; }
catch (FormatException) { return null; }
catch (OverflowException) { return null; }
catch (InvalidCastException) { return null; }
}

private static int? ToInt(object? o)
{
if (o == null) return null;
try { return Convert.ToInt32(o); } catch { return null; }
try { return Convert.ToInt32(o); }
catch (FormatException) { return null; }
catch (OverflowException) { return null; }
catch (InvalidCastException) { return null; }
}

private static long? ToLong(object? o)
{
if (o == null) return null;
try { var v = Convert.ToInt64(o); return v == 0 ? null : v; } catch { return null; }
try { var v = Convert.ToInt64(o); return v == 0 ? null : v; }
catch (FormatException) { return null; }
catch (OverflowException) { return null; }
catch (InvalidCastException) { return null; }
}

private static string MapMedia(uint v) => v switch
Expand Down
9 changes: 6 additions & 3 deletions SysManager/SysManager/Services/EventLogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ private async IAsyncEnumerable<FriendlyEventEntry> ReadInternal(

FriendlyEventEntry? entry = null;
try { entry = Project(rec, opt.LogName); }
catch { /* skip malformed record */ }
catch (EventLogException) { /* skip malformed record */ }
catch (InvalidOperationException) { /* skip malformed record */ }
finally { rec.Dispose(); }

if (entry == null) continue;
Expand Down Expand Up @@ -96,15 +97,17 @@ private static string SafeFormatMessage(EventRecord rec)
var msg = rec.FormatDescription();
if (!string.IsNullOrWhiteSpace(msg)) return msg;
}
catch { /* ignore; fall back */ }
catch (EventLogException) { /* format failed — fall back */ }
catch (InvalidOperationException) { /* format failed — fall back */ }

// Fallback: assemble from properties so we at least show something.
try
{
var parts = rec.Properties?.Select(p => p?.Value?.ToString() ?? "") ?? [];
return string.Join(" ", parts).Trim();
}
catch { return "(message not available)"; }
catch (EventLogException) { return "(message not available)"; }
catch (InvalidOperationException) { return "(message not available)"; }
}

private static string FirstLine(string s)
Expand Down
3 changes: 2 additions & 1 deletion SysManager/SysManager/Services/PingMonitorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ private void RaiseSampleReceived(PingSample sample)
foreach (var h in handlers)
{
try { ((Action<PingSample>)h).Invoke(sample); }
catch { /* swallow subscriber errors */ }
catch (ObjectDisposedException) { /* subscriber disposed — skip */ }
catch (InvalidOperationException) { /* subscriber error — skip */ }
Comment on lines +137 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep subscriber isolation for all handler failures.

Catching only two exception types allows other subscriber exceptions to break the handler loop, which violates the isolation contract in this method.

Suggested fix
         foreach (var h in handlers)
         {
             try { ((Action<PingSample>)h).Invoke(sample); }
             catch (ObjectDisposedException) { /* subscriber disposed — skip */ }
             catch (InvalidOperationException) { /* subscriber error — skip */ }
+            catch (Exception) { /* unexpected subscriber failure — skip to preserve fan-out isolation */ }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/Services/PingMonitorService.cs` around lines 137 - 138,
The handler loop currently only catches ObjectDisposedException and
InvalidOperationException which allows other exceptions from subscribers to
escape and break the loop; update the catch logic in the PingMonitorService
subscriber handler loop (the block that currently has "catch
(ObjectDisposedException) { ... }" and "catch (InvalidOperationException) { ...
}") to catch all exceptions (e.g., catch Exception ex) so any subscriber failure
is isolated, log the exception details for diagnostics, and continue the loop
rather than letting the exception propagate.

}
}

Expand Down
14 changes: 9 additions & 5 deletions SysManager/SysManager/Services/PowerShellRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ namespace SysManager.Services;
/// Runs PowerShell scripts in-process with live streaming of all output streams.
/// Uses System.Management.Automation so we don't spawn external pwsh.exe processes.
///
/// <para><b>Security note (SEC-005):</b> ExecutionPolicy is set to Bypass for in-process
/// runspaces because SysManager only executes its own static scripts — never user-supplied
/// or downloaded scripts. RunScriptViaPwshAsync also uses -ExecutionPolicy Bypass for the
/// same reason. Callers that use RunAsync or RunScriptViaPwshAsync must only pass
/// hard-coded script strings; never pass user input directly as a script.</para>
/// <para><b>Security note (SEC-005 / SEC-M8):</b> ExecutionPolicy is set to Bypass for
/// in-process runspaces because SysManager only executes its own static scripts — never
/// user-supplied or downloaded scripts. RunScriptViaPwshAsync also uses -ExecutionPolicy
/// Bypass for the same reason.</para>
///
/// <para><b>SECURITY CONTRACT:</b> Callers MUST only pass hard-coded script strings to
/// RunAsync and RunScriptViaPwshAsync. User input MUST NEVER be interpolated into scripts.
/// Violation of this contract creates a code injection vulnerability. The Bypass policy
/// is safe ONLY because the script content is fully controlled by SysManager's source code.</para>
/// </summary>
public sealed class PowerShellRunner
{
Expand Down
12 changes: 11 additions & 1 deletion SysManager/SysManager/Services/ServiceManagerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,18 @@ private static string GetServiceDescription(ServiceController sc)
{
try
{
// SEC-M6: Validate service name before interpolating into registry path.
// Although ServiceController.GetServices() returns names from the SCM
// (trusted source), we defensively reject names with path separators or
// registry metacharacters to prevent registry path traversal.
var name = sc.ServiceName;
if (string.IsNullOrWhiteSpace(name) ||
name.Contains('\\') || name.Contains('/') ||
name.Contains('\0'))
return "";

using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(
$@"SYSTEM\CurrentControlSet\Services\{sc.ServiceName}");
$@"SYSTEM\CurrentControlSet\Services\{name}");
return key?.GetValue("Description")?.ToString() ?? "";
}
catch (System.Security.SecurityException) { return ""; }
Expand Down
33 changes: 31 additions & 2 deletions SysManager/SysManager/Services/SpeedTestService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,12 @@
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
ErrorDialog = false // suppress Win32 "DLL not found" system dialogs
ErrorDialog = false, // suppress Win32 "DLL not found" system dialogs
// SEC-M4: Set working directory to System32 instead of the tools dir.
// This prevents DLL hijacking via CWD search order — if an attacker
// plants a malicious DLL in the user-writable tools directory, the
// process won't load it because CWD is System32 (admin-protected).
WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.System)
};

// Start the process on a thread-pool thread so Process.Start()
Expand Down Expand Up @@ -342,7 +347,31 @@
progress?.Report((15, "Extracting…"));
await Task.Run(() =>
{
ZipFile.ExtractToDirectory(zipPath, toolsDir, overwriteFiles: true);
// SEC-M3: Manual extraction with Zip Slip protection.
// ZipFile.ExtractToDirectory does not validate that entry paths
// stay within the target directory — a crafted zip with "../"
// entries could write files outside toolsDir.
using var archive = ZipFile.OpenRead(zipPath);
var fullToolsDir = Path.GetFullPath(toolsDir);
foreach (var entry in archive.Entries)
{
// Skip directory entries
if (string.IsNullOrEmpty(entry.Name)) continue;

var destinationPath = Path.GetFullPath(Path.Combine(toolsDir, entry.FullName));

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.
if (!destinationPath.StartsWith(fullToolsDir, StringComparison.OrdinalIgnoreCase))
{
Comment on lines +355 to +363

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a directory-boundary check to the Zip Slip guard.

StartsWith(fullToolsDir) still accepts sibling escapes like ..\tools_evil\payload.dll, because C:\...\tools_evil\... shares the same string prefix as C:\...\tools. This can still extract outside toolsDir.

Suggested fix
-            var fullToolsDir = Path.GetFullPath(toolsDir);
+            var fullToolsDir = Path.GetFullPath(toolsDir)
+                .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+                + Path.DirectorySeparatorChar;
             foreach (var entry in archive.Entries)
             {
                 // Skip directory entries
                 if (string.IsNullOrEmpty(entry.Name)) continue;
 
                 var destinationPath = Path.GetFullPath(Path.Combine(toolsDir, entry.FullName));
                 if (!destinationPath.StartsWith(fullToolsDir, StringComparison.OrdinalIgnoreCase))
                 {
                     Log.Warning("Zip Slip attempt blocked: {Entry} resolves outside target dir", entry.FullName);
                     continue;
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var fullToolsDir = Path.GetFullPath(toolsDir);
foreach (var entry in archive.Entries)
{
// Skip directory entries
if (string.IsNullOrEmpty(entry.Name)) continue;
var destinationPath = Path.GetFullPath(Path.Combine(toolsDir, entry.FullName));
if (!destinationPath.StartsWith(fullToolsDir, StringComparison.OrdinalIgnoreCase))
{
var fullToolsDir = Path.GetFullPath(toolsDir)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
Path.DirectorySeparatorChar;
foreach (var entry in archive.Entries)
{
// Skip directory entries
if (string.IsNullOrEmpty(entry.Name)) continue;
var destinationPath = Path.GetFullPath(Path.Combine(toolsDir, entry.FullName));
if (!destinationPath.StartsWith(fullToolsDir, StringComparison.OrdinalIgnoreCase))
{
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/Services/SpeedTestService.cs` around lines 355 - 363,
The Zip-Slip guard using destinationPath.StartsWith(fullToolsDir) is
insufficient because sibling paths like ..\tools_evil share the same prefix;
update the check around archive.Entries extraction (variables: fullToolsDir,
destinationPath, toolsDir) to validate that destinationPath is truly inside
toolsDir by computing a relative path (e.g., Path.GetRelativePath(fullToolsDir,
destinationPath)) and rejecting any result that starts with ".." or is equal to
"..", or alternatively ensure the prefix comparison includes a directory
separator boundary; only proceed with extraction when the relative path
indicates the file is within the toolsDir tree.

Log.Warning("Zip Slip attempt blocked: {Entry} resolves outside target dir", entry.FullName);
continue;
}

// Ensure subdirectory exists
var entryDir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(entryDir))
Directory.CreateDirectory(entryDir);

entry.ExtractToFile(destinationPath, overwrite: true);
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.
Comment on lines +356 to +374
File.Delete(zipPath);
}, ct).ConfigureAwait(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ private async Task PumpAsync(CancellationToken ct)
RouteCompleted?.Invoke(target.Host, hops);
}
catch (OperationCanceledException) { return; }
catch { /* swallow per-target errors */ }
catch (System.Net.Sockets.SocketException) { /* network error — skip */ }
catch (System.Net.NetworkInformation.PingException) { /* ping failed — skip */ }
catch (TimeoutException) { /* target unreachable — skip */ }
catch (InvalidOperationException) { /* traceroute failed for this target — skip */ }
}

try { await Task.Delay(Interval, ct); }
Expand Down
3 changes: 2 additions & 1 deletion SysManager/SysManager/Services/TracerouteService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ private void RaiseHopCompleted(TracerouteHop hop)
foreach (var h in handlers)
{
try { ((Action<TracerouteHop>)h).Invoke(hop); }
catch (Exception) { /* swallow subscriber errors to protect iteration */ }
catch (ObjectDisposedException) { /* subscriber disposed — skip */ }
catch (InvalidOperationException) { /* subscriber error — skip */ }
Comment on lines +106 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent subscriber exceptions from aborting traceroute emission.

With only two catches, a different handler exception can escape and terminate RunAsync, despite this method’s isolation contract.

Suggested fix
         foreach (var h in handlers)
         {
             try { ((Action<TracerouteHop>)h).Invoke(hop); }
             catch (ObjectDisposedException) { /* subscriber disposed — skip */ }
             catch (InvalidOperationException) { /* subscriber error — skip */ }
+            catch (Exception) { /* unexpected subscriber failure — skip to preserve traceroute execution */ }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/Services/TracerouteService.cs` around lines 106 - 107,
The RunAsync loop in TracerouteService currently only catches
ObjectDisposedException and InvalidOperationException, so other subscriber
exceptions can bubble out and abort traceroute emission; update the exception
handling in RunAsync (around the subscriber publish/emit logic) to catch a
broader exception (e.g., Exception) so any unexpected subscriber error is
swallowed and does not terminate the loop, and ensure the catch logs the
exception (using the existing logger or Trace) with context instead of silently
ignoring it; keep the specific catches if you want special handling for
ObjectDisposedException/InvalidOperationException but add a final catch-all for
other exceptions to preserve the method's isolation contract.

}
}
}
42 changes: 34 additions & 8 deletions SysManager/SysManager/Services/UninstallerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,14 @@ internal static (string Exe, string Args) ParseUninstallCommand(string command)
{
command = command.Trim();

// SEC-M7: Reject obviously malicious patterns before parsing.
// Commands containing shell metacharacters that could chain commands.
if (command.Contains('|') || command.Contains('&') ||
command.Contains(';') || command.Contains('`') ||
command.Contains("$("))
throw new InvalidOperationException(
$"Uninstall command contains shell metacharacters — refusing to parse: '{command}'");
Comment on lines +321 to +327

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Parse first; this raw blacklist still allows shell hosts and now rejects valid quoted paths.

Because this check runs before the quoted-path branch, legitimate commands like "C:\Program Files\AT&T\App\uninstall.exe" /S now fail purely due to the folder name. At the same time, C:\Windows\System32\cmd.exe /c C:\Users\...\evil.exe contains none of these characters, so it still gets through and later passes the trusted-directory check. The safer boundary here is the parsed executable: keep the narrow allowlist for known uninstall hosts (msiexec, rundll32) and explicitly reject shell/interpreter binaries (cmd, powershell, pwsh, wscript, cscript, mshta, etc.) instead of scanning the raw command string.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/Services/UninstallerService.cs` around lines 321 - 327,
The current pre-parse blacklist on the raw command string in UninstallerService
(the check on variable command) incorrectly rejects valid quoted paths and still
allows shell-hosted attacks; instead move this validation to run after you parse
the command/executable (i.e., after the quoted-path branch and before the
trusted-directory check), and replace the raw-character reject with a narrow
allowlist for known uninstall hosts (e.g., "msiexec", "rundll32") plus an
explicit denylist of interpreter/shell binaries (e.g., "cmd", "powershell",
"pwsh", "wscript", "cscript", "mshta") applied to the parsed executable name;
ensure the logic references the parsed executable (not the raw command string)
and keeps existing trusted-directory checks intact.


// Case 1: Quoted executable path
if (command.StartsWith('"'))
{
Expand Down Expand Up @@ -360,17 +368,35 @@ internal static (string Exe, string Args) ParseUninstallCommand(string command)
return (command[..spaceIdx], command[(spaceIdx + 1)..].TrimStart());
}

// Case 4: Unquoted path with spaces — find first .exe boundary
var exeEnd = command.IndexOf(".exe", StringComparison.OrdinalIgnoreCase);
if (exeEnd > 0)
// Case 4: Unquoted path with spaces — find first .exe boundary.
// SEC-M7: Only match ".exe" followed by end-of-string, whitespace, or a
// switch character (/,-). This prevents misparsing paths like
// "C:\dir\app.executable\tool.exe" where an earlier ".exe" substring
// would incorrectly split the path.
var searchStart = 0;
while (searchStart < command.Length)
{
var exeEnd = command.IndexOf(".exe", searchStart, StringComparison.OrdinalIgnoreCase);
if (exeEnd < 0) break;

exeEnd += 4; // include ".exe"
var exe = command[..exeEnd].Trim();
var args = exeEnd < command.Length ? command[exeEnd..].TrimStart() : "";
return (exe, args);
// Valid boundary: end of string, or followed by whitespace/switch
if (exeEnd >= command.Length ||
command[exeEnd] == ' ' || command[exeEnd] == '\t' ||
command[exeEnd] == '/' || command[exeEnd] == '-')
{
var exe = command[..exeEnd].Trim();
var args = exeEnd < command.Length ? command[exeEnd..].TrimStart() : "";
return (exe, args);
}
// Not a valid boundary — keep searching after this occurrence
searchStart = exeEnd;
}

// Fallback: treat entire string as executable
return (command, "");
// SEC-M7: No fallback — if we can't parse it safely, reject it.
// The old fallback treated the entire string as an executable, which
// could execute arbitrary commands if the string was crafted.
throw new InvalidOperationException(
$"Cannot safely parse uninstall command — no valid executable found: '{command}'");
}
}
45 changes: 42 additions & 3 deletions SysManager/SysManager/Services/UpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,35 @@ public async Task<IReadOnlyList<ReleaseInfo>> GetRecentAsync(int count = 10, Can
Directory.CreateDirectory(dir);
var target = Path.Combine(dir, $"SysManager-{rel.Version}.exe");

// Skip re-download if we already have a good copy.
if (File.Exists(target) && rel.AssetSize.HasValue && new FileInfo(target).Length == rel.AssetSize.Value)
return target;
// SEC-M2: Skip re-download only if we have a cached hash that matches.
// File size alone is insufficient — an attacker could replace the binary
// with a same-size payload. We store a companion .sha256 file after each
// successful download to enable fast cache validation without re-downloading
// the hash from GitHub on every launch.
var hashFile = target + ".sha256";
if (File.Exists(target) && File.Exists(hashFile))
{
try
{
var cachedHash = (await File.ReadAllTextAsync(hashFile, ct).ConfigureAwait(false)).Trim();
if (cachedHash.Length >= 64)
{
var actualHash = await Task.Run(() =>
{
using var stream = File.OpenRead(target);
return Convert.ToHexString(SHA256.HashData(stream));
}, ct).ConfigureAwait(false);

if (string.Equals(cachedHash, actualHash, StringComparison.OrdinalIgnoreCase))
return target;

// Hash mismatch — cached file is corrupt or tampered, re-download.
Serilog.Log.Warning("Cached update binary hash mismatch — re-downloading");
}
}
catch (IOException) { /* hash file unreadable — re-download */ }
catch (UnauthorizedAccessException) { /* hash file unreadable — re-download */ }
}

try
{
Expand All @@ -166,6 +192,19 @@ public async Task<IReadOnlyList<ReleaseInfo>> GetRecentAsync(int count = 10, Can
progress?.Report((read, total));
}

// SEC-M2: Store SHA-256 hash of the downloaded binary for cache validation.
try
{
var downloadedHash = await Task.Run(() =>
{
using var hashStream = File.OpenRead(target);
return Convert.ToHexString(SHA256.HashData(hashStream));
}, ct).ConfigureAwait(false);
await File.WriteAllTextAsync(hashFile, downloadedHash, ct).ConfigureAwait(false);
}
catch (IOException) { /* non-fatal — next launch will re-download */ }
catch (UnauthorizedAccessException) { /* non-fatal */ }
Comment on lines +195 to +206

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

File stream still open — hash computation always fails silently.

The file stream from line 183 (await using var file = File.Create(target)) is not disposed until the end of the try block. When File.OpenRead(target) at line 200 attempts to open the same file, it throws IOException due to exclusive file locking. This exception is caught at line 205 and swallowed, so the .sha256 file is never written.

This defeats the SEC-M2 fix: without a hash file, every launch triggers a full re-download.

🐛 Proposed fix: Flush and close the write stream before computing hash
             read += n;
             progress?.Report((read, total));
         }
+        
+        // Close write stream before reopening for hash computation
+        await file.DisposeAsync().ConfigureAwait(false);

         // SEC-M2: Store SHA-256 hash of the downloaded binary for cache validation.
         try
         {
             var downloadedHash = await Task.Run(() =>
             {
                 using var hashStream = File.OpenRead(target);
                 return Convert.ToHexString(SHA256.HashData(hashStream));
             }, ct).ConfigureAwait(false);
             await File.WriteAllTextAsync(hashFile, downloadedHash, ct).ConfigureAwait(false);
         }

Alternatively, restructure to use a scoped block for the write stream:

{
    await using var file = File.Create(target);
    // ... write loop ...
} // file disposed here

// Now safe to read
try
{
    var downloadedHash = ...
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/Services/UpdateService.cs` around lines 195 - 206, The
hash computation fails because the write stream created with "await using var
file = File.Create(target)" remains open when File.OpenRead(target) is called;
close or dispose the write stream before computing the SHA-256 so File.OpenRead
can succeed. Fix by scoping or explicitly closing the write stream (e.g., end
the await-using block or call file.FlushAsync/Dispose/Close after the write
loop) prior to the block that computes downloadedHash with File.OpenRead(target)
and SHA256.HashData, then write the hashFile; keep the existing exception
handling for File IO.


return target;
}
catch (OperationCanceledException)
Expand Down
Loading