mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
fix(security): startup self-update no longer trusts the on-disk remote-config.json cache — Program.LoadCachedSelfUpdateContext (raw deserialize, bypassed the read-side strip) is removed, the boot-time check runs with allowDowngrade:false and the version-v2.json manifest hash as its only integrity gate; LatestAgentSha256 (update-ZIP hash, ranked above the manifest hash in SelfUpdater) joins the CacheConfig/LoadCachedConfig strip lists so neither the startup nor the FromCache runtime path can take a planted ZIP hash or downgrade flag; verify+extract already share one exclusive handle (1daf631a)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+17
-5
@@ -17,7 +17,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
/// (RemoteConfigService.cs:234-311). The on-disk config cache MUST NOT persist — nor,
|
||||
/// on read, trust — security-sensitive fields: a single MITM during OOBE could otherwise
|
||||
/// plant attacker-controlled values (UnrestrictedMode / AllowAgentDowngrade /
|
||||
/// LatestAgentExeSha256 / DeviceBlocked / DeviceKillSignal / UnblockAt) that survive every
|
||||
/// LatestAgentExeSha256 / LatestAgentSha256 / DeviceBlocked / DeviceKillSignal / UnblockAt /
|
||||
/// MigrateToApiBaseUrl) that survive every
|
||||
/// future cold boot. The strip is defence-in-depth on BOTH paths — <c>CacheConfig</c> (write)
|
||||
/// and <c>LoadCachedConfig</c> (read) — so both are asserted here.
|
||||
/// <para>
|
||||
@@ -59,6 +60,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
UnrestrictedMode = true,
|
||||
AllowAgentDowngrade = true,
|
||||
LatestAgentExeSha256 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||||
LatestAgentSha256 = "badc0ffebadc0ffebadc0ffebadc0ffebadc0ffebadc0ffebadc0ffebadc0ffe",
|
||||
DeviceBlocked = true,
|
||||
DeviceKillSignal = true,
|
||||
UnblockAt = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
@@ -75,13 +77,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.False(persisted.UnrestrictedMode);
|
||||
Assert.False(persisted.AllowAgentDowngrade);
|
||||
Assert.Null(persisted.LatestAgentExeSha256);
|
||||
Assert.Null(persisted.LatestAgentSha256);
|
||||
Assert.False(persisted.DeviceBlocked);
|
||||
Assert.False(persisted.DeviceKillSignal);
|
||||
Assert.Null(persisted.UnblockAt);
|
||||
Assert.Null(persisted.MigrateToApiBaseUrl);
|
||||
|
||||
// The attacker EXE hash / re-home host must not appear anywhere in the persisted bytes.
|
||||
// The attacker EXE hash / ZIP hash / re-home host must not appear anywhere in the persisted bytes.
|
||||
Assert.DoesNotContain("deadbeef", raw);
|
||||
Assert.DoesNotContain("badc0ffe", raw);
|
||||
Assert.DoesNotContain("attacker.azurewebsites.net", raw);
|
||||
|
||||
// Benign fields survive the strip.
|
||||
@@ -93,6 +97,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.True(config.UnrestrictedMode);
|
||||
Assert.True(config.AllowAgentDowngrade);
|
||||
Assert.Equal("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", config.LatestAgentExeSha256);
|
||||
Assert.Equal("badc0ffebadc0ffebadc0ffebadc0ffebadc0ffebadc0ffebadc0ffebadc0ffe", config.LatestAgentSha256);
|
||||
Assert.True(config.DeviceBlocked);
|
||||
Assert.True(config.DeviceKillSignal);
|
||||
Assert.Equal(new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), config.UnblockAt);
|
||||
@@ -105,6 +110,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
[InlineData("UnrestrictedMode")]
|
||||
[InlineData("AllowAgentDowngrade")]
|
||||
[InlineData("LatestAgentExeSha256")]
|
||||
[InlineData("LatestAgentSha256")]
|
||||
[InlineData("DeviceBlocked")]
|
||||
[InlineData("DeviceKillSignal")]
|
||||
[InlineData("UnblockAt")]
|
||||
@@ -122,6 +128,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
UnrestrictedMode = true,
|
||||
AllowAgentDowngrade = true,
|
||||
LatestAgentExeSha256 = "0011223344556677889900112233445566778899001122334455667788990011",
|
||||
LatestAgentSha256 = "1122334455667788990011223344556677889900112233445566778899001122",
|
||||
DeviceBlocked = true,
|
||||
DeviceKillSignal = true,
|
||||
UnblockAt = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
@@ -143,6 +150,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
case "LatestAgentExeSha256":
|
||||
Assert.Null(loaded.LatestAgentExeSha256);
|
||||
break;
|
||||
case "LatestAgentSha256":
|
||||
// The update-ZIP hash outranks the version-v2.json manifest hash inside
|
||||
// SelfUpdater — a planted value would let an attacker ZIP pass verification.
|
||||
Assert.Null(loaded.LatestAgentSha256);
|
||||
break;
|
||||
case "DeviceBlocked":
|
||||
Assert.False(loaded.DeviceBlocked);
|
||||
break;
|
||||
@@ -181,8 +193,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
NtpServer = "time.contoso.com",
|
||||
LogLevel = "Debug",
|
||||
DiagnosticsUploadMode = "OnFailure",
|
||||
// A non-sensitive hash field (ZIP hash) that must NOT be stripped — only the
|
||||
// EXE hash (LatestAgentExeSha256) is security-sensitive on the cache path.
|
||||
// Both backend hashes are live-fetch-only — the round-trip must drop them
|
||||
// while every benign field above survives untouched.
|
||||
LatestAgentSha256 = "cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe",
|
||||
};
|
||||
|
||||
@@ -199,7 +211,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal("time.contoso.com", loaded.NtpServer);
|
||||
Assert.Equal("Debug", loaded.LogLevel);
|
||||
Assert.Equal("OnFailure", loaded.DiagnosticsUploadMode);
|
||||
Assert.Equal("cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe", loaded.LatestAgentSha256);
|
||||
Assert.Null(loaded.LatestAgentSha256);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -323,6 +323,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
// - AllowAgentDowngrade: gate for installing a lower agent version
|
||||
// - LatestAgentExeSha256: backend-advertised EXE hash for runtime integrity
|
||||
// check; a bad cached value would trigger a force-update to attacker bins
|
||||
// - LatestAgentSha256: backend-advertised update ZIP hash; SelfUpdater ranks it
|
||||
// ABOVE the version-v2.json manifest hash, so a planted cached value would
|
||||
// make an attacker ZIP pass integrity verification (or pin the device by
|
||||
// failing every genuine update). Without a live value the updater falls
|
||||
// back to the manifest hash.
|
||||
// - DeviceBlocked/DeviceKillSignal/UnblockAt: kill is only honoured from a
|
||||
// live fetch — a planted cached kill would self-destruct every future
|
||||
// session, a stale cached kill would outlive the admin removing the rule
|
||||
@@ -332,6 +337,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
var liveUnrestricted = config.UnrestrictedMode;
|
||||
var liveAllowDowngrade = config.AllowAgentDowngrade;
|
||||
var liveExeHash = config.LatestAgentExeSha256;
|
||||
var liveZipHash = config.LatestAgentSha256;
|
||||
var liveBlocked = config.DeviceBlocked;
|
||||
var liveKill = config.DeviceKillSignal;
|
||||
var liveUnblockAt = config.UnblockAt;
|
||||
@@ -339,6 +345,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
config.UnrestrictedMode = false;
|
||||
config.AllowAgentDowngrade = false;
|
||||
config.LatestAgentExeSha256 = null;
|
||||
config.LatestAgentSha256 = null;
|
||||
config.DeviceBlocked = false;
|
||||
config.DeviceKillSignal = false;
|
||||
config.UnblockAt = null;
|
||||
@@ -347,6 +354,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
config.UnrestrictedMode = liveUnrestricted;
|
||||
config.AllowAgentDowngrade = liveAllowDowngrade;
|
||||
config.LatestAgentExeSha256 = liveExeHash;
|
||||
config.LatestAgentSha256 = liveZipHash;
|
||||
config.DeviceBlocked = liveBlocked;
|
||||
config.DeviceKillSignal = liveKill;
|
||||
config.UnblockAt = liveUnblockAt;
|
||||
@@ -379,6 +387,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
config.UnrestrictedMode = false;
|
||||
config.AllowAgentDowngrade = false;
|
||||
config.LatestAgentExeSha256 = null;
|
||||
config.LatestAgentSha256 = null;
|
||||
config.DeviceBlocked = false;
|
||||
config.DeviceKillSignal = false;
|
||||
config.UnblockAt = null;
|
||||
|
||||
@@ -83,8 +83,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
/// <summary>
|
||||
/// Optional: backend-provided SHA-256 hash of the latest agent ZIP.
|
||||
/// Set from the last AgentConfigResponse before calling CheckAndApplyUpdateAsync.
|
||||
/// Takes priority over the hash in version-v2.json (separate trust channel).
|
||||
/// Set only from a LIVE authenticated AgentConfigResponse (never from the on-disk
|
||||
/// remote-config.json cache — the field is stripped there) before calling
|
||||
/// CheckAndApplyUpdateAsync. Takes priority over the hash in version-v2.json
|
||||
/// (separate trust channel); when unset the manifest hash is the integrity gate.
|
||||
/// </summary>
|
||||
public static string BackendExpectedSha256 { get; set; }
|
||||
|
||||
@@ -294,7 +296,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!string.IsNullOrEmpty(BackendExpectedSha256))
|
||||
{
|
||||
expectedSha256 = BackendExpectedSha256;
|
||||
log("Self-update: using backend hash for integrity verification (cached config — trusted channel)");
|
||||
log("Self-update: using backend hash for integrity verification (live remote config — separate trust channel)");
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(manifestSha256))
|
||||
{
|
||||
|
||||
@@ -40,6 +40,10 @@ namespace AutopilotMonitor.Agent.V2
|
||||
|
||||
private const string ImePatternsGuidPattern = @"(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})";
|
||||
private const string ImePatternsGitHubUrl = "https://raw.githubusercontent.com/okieselbach/Autopilot-Monitor/refs/heads/main/rules/dist/ime-log-patterns.json";
|
||||
// Debug-mode-only fallback source for IME patterns (offline --run-ime-matching). The
|
||||
// runtime never reads this file directly — RemoteConfigService.LoadCachedConfig is the
|
||||
// only production reader and strips the live-fetch-only security fields.
|
||||
private const string CachedRemoteConfigPath = @"%ProgramData%\AutopilotMonitor\Config\remote-config.json";
|
||||
|
||||
internal static int RunImeMatchingMode(string[] args)
|
||||
{
|
||||
|
||||
@@ -35,7 +35,6 @@ namespace AutopilotMonitor.Agent.V2
|
||||
/// <item>Register <c>ProcessExit</c> → writes <c>clean-exit.marker</c></item>
|
||||
/// <item>Ensure agent directories exist</item>
|
||||
/// <item><see cref="SelfUpdater.LogInit"/> + <see cref="SelfUpdater.CleanupPreviousUpdate"/></item>
|
||||
/// <item>Load cached <c>remote-config.json</c> → <see cref="SelfUpdater.BackendExpectedSha256"/> + <c>AllowAgentDowngrade</c></item>
|
||||
/// <item><see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> — on success restarts the process; on failure continues with current binary</item>
|
||||
/// <item><see cref="DetectPreviousExit"/> — reads markers + event log to classify last shutdown</item>
|
||||
/// <item>Resolve TenantId (registry → bootstrap-config.json fallback)</item>
|
||||
@@ -60,7 +59,6 @@ namespace AutopilotMonitor.Agent.V2
|
||||
private const string DefaultAgentSubdirectory = "Agent";
|
||||
private const string DefaultStateSubdirectory = "State";
|
||||
private const string DefaultSpoolSubdirectory = "Spool";
|
||||
private const string CachedRemoteConfigPath = @"%ProgramData%\AutopilotMonitor\Config\remote-config.json";
|
||||
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
@@ -132,23 +130,27 @@ namespace AutopilotMonitor.Agent.V2
|
||||
tenantId: "(pre-runtime)",
|
||||
agentVersion: GetAgentVersion());
|
||||
|
||||
// Startup self-update. Legacy parity: cleanup leftover .old files, load cached
|
||||
// backend hash / downgrade policy, then attempt the update. Failures here never
|
||||
// abort startup — we prefer to run the current version than delay.
|
||||
// Startup self-update. Legacy parity: cleanup leftover .old files, then attempt the
|
||||
// update against the version-v2.json manifest. Failures here never abort startup —
|
||||
// we prefer to run the current version than delay.
|
||||
SelfUpdater.LogInit(GetAgentVersion());
|
||||
|
||||
var agentDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty;
|
||||
SelfUpdater.CleanupPreviousUpdate(agentDir, msg => { if (consoleMode) Console.Out.WriteLine(msg); });
|
||||
|
||||
var allowAgentDowngrade = LoadCachedSelfUpdateContext();
|
||||
|
||||
// SECURITY: the startup check runs pre-backend, so it deliberately reads NOTHING from
|
||||
// the on-disk remote-config.json cache. AllowAgentDowngrade and the ZIP hash
|
||||
// (LatestAgentSha256) are live-fetch-only fields (see RemoteConfigService.CacheConfig)
|
||||
// — a planted cache file must neither lift the forward-only gate nor replace the
|
||||
// version-v2.json manifest hash as the ZIP integrity check. Downgrades therefore only
|
||||
// happen from the runtime trigger after an authenticated fetch.
|
||||
try
|
||||
{
|
||||
SelfUpdater.CheckAndApplyUpdateAsync(
|
||||
currentVersion: GetAgentVersion(),
|
||||
agentDir: agentDir,
|
||||
consoleMode: consoleMode,
|
||||
allowDowngrade: allowAgentDowngrade).GetAwaiter().GetResult();
|
||||
allowDowngrade: false).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception selfUpdateEx)
|
||||
{
|
||||
@@ -396,33 +398,6 @@ namespace AutopilotMonitor.Agent.V2
|
||||
};
|
||||
}
|
||||
|
||||
private static bool LoadCachedSelfUpdateContext()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Environment.ExpandEnvironmentVariables(CachedRemoteConfigPath);
|
||||
if (!File.Exists(path)) return false;
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
var cached = Newtonsoft.Json.JsonConvert.DeserializeObject<AgentConfigResponse>(json);
|
||||
if (cached == null) return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(cached.LatestAgentSha256))
|
||||
{
|
||||
SelfUpdater.BackendExpectedSha256 = cached.LatestAgentSha256;
|
||||
SelfUpdater.Log(
|
||||
$"Self-update: loaded backend integrity hash from cached config (sha256={cached.LatestAgentSha256.Substring(0, Math.Min(12, cached.LatestAgentSha256.Length))}...)");
|
||||
}
|
||||
|
||||
return cached.AllowAgentDowngrade;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SelfUpdater.Log($"Self-update: cached config read failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string GetArgValue(string[] args, params string[] names)
|
||||
{
|
||||
if (args == null || args.Length < 2 || names == null) return null;
|
||||
|
||||
Reference in New Issue
Block a user