Harden the agent's command execution and restore server certificate checks
Agent 1.19.0. - With no pinned thumbprint the collector, command and fleet alert clients accepted any server certificate. They now require a valid chain again. Production uses a self-signed certificate, so this build must not reach bays before server pinning ships with it. - SYSTEM script runs read stdout and stderr while the script runs, cap what is kept, and kill the whole process tree after five minutes. A chatty script used to fill the pipe and hang the command channel until a service restart. - Scripts and update_app packages are staged in a directory only SYSTEM and Administrators can touch, under a random name, with the hash checked in memory and again on disk. A standard user could previously swap the file between the hash check and execution. - The user-context transcript lives in a SYSTEM-created directory, and SYSTEM refuses to follow a link when reading or deleting it. - Strict mode is inserted after any param block, #Requires or using lines, keeps line numbers and writes UTF-8 with a BOM. - A user-context run that reports no exit code now fails. - collect_log refuses setup answer files, appsettings files, the agent's own directories, key files and links. - The startup renewal check is capped at 15 seconds and exits non-zero when it wants a restart, so service recovery restarts the agent.
This commit is contained in:
@@ -52,7 +52,7 @@ public sealed class CollectorClient : IDisposable
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
|
||||
// Server certificate validation: pinning when configured, otherwise accept all
|
||||
// Server certificate validation: pinning when configured, otherwise chain validation
|
||||
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
|
||||
{
|
||||
if (cert == null)
|
||||
@@ -79,8 +79,14 @@ public sealed class CollectorClient : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
// No pinning configured: accept all (insecure, for rollout compatibility)
|
||||
return true;
|
||||
// No pinning configured: normal Windows chain validation
|
||||
if (errors == System.Net.Security.SslPolicyErrors.None)
|
||||
return true;
|
||||
|
||||
logger.LogWarning(
|
||||
"Server certificate rejected on collector channel: {Errors} ({Subject})",
|
||||
errors, cert.Subject);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Attach client certificate if configured
|
||||
|
||||
@@ -88,8 +88,14 @@ public sealed class CommandClient : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
// No pinning configured: accept all (insecure, for rollout compatibility)
|
||||
return true;
|
||||
// No pinning configured: normal Windows chain validation
|
||||
if (errors == System.Net.Security.SslPolicyErrors.None)
|
||||
return true;
|
||||
|
||||
logger.LogWarning(
|
||||
"Server certificate rejected on command channel: {Errors} ({Subject})",
|
||||
errors, cert.Subject);
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Principal;
|
||||
using System.ServiceProcess;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -306,14 +308,38 @@ public sealed class CommandExecutor
|
||||
};
|
||||
}
|
||||
|
||||
var totalBytes = info.Length;
|
||||
var truncated = totalBytes > MaxLogBytes;
|
||||
|
||||
string content;
|
||||
using (var stream = new FileStream(
|
||||
path, FileMode.Open, FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete))
|
||||
if (ProtectedFileSystem.IsReparsePoint(path))
|
||||
{
|
||||
logger.LogWarning("collect_log refused {Path}: reparse point", path);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"{path} is a link or reparse point; only regular files can be collected",
|
||||
};
|
||||
}
|
||||
|
||||
long totalBytes;
|
||||
bool truncated;
|
||||
string content;
|
||||
using (var stream = ProtectedFileSystem.OpenReadNoFollow(path))
|
||||
{
|
||||
// Re-check where the handle really landed. A junction in the
|
||||
// ancestry or a short 8.3 name would otherwise walk past the
|
||||
// denylist, which only saw the typed path.
|
||||
var finalPath = ProtectedFileSystem.FinalPath(stream.SafeFileHandle);
|
||||
var finalRejection = finalPath is null
|
||||
? "Could not resolve the real path of the file"
|
||||
: DeniedLogPathReason(finalPath);
|
||||
if (finalRejection != null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"collect_log refused {Path} (resolves to {FinalPath}): {Reason}",
|
||||
path, finalPath, finalRejection);
|
||||
return new CommandResult { Succeeded = false, Message = finalRejection };
|
||||
}
|
||||
|
||||
totalBytes = stream.Length;
|
||||
truncated = totalBytes > MaxLogBytes;
|
||||
if (truncated)
|
||||
{
|
||||
// Seek to the tail. This can land mid-line, so the partial
|
||||
@@ -450,6 +476,57 @@ public sealed class CommandExecutor
|
||||
return "That path is a directory, not a log file";
|
||||
|
||||
path = Path.GetFullPath(normalized);
|
||||
return DeniedLogPathReason(path);
|
||||
}
|
||||
|
||||
// Files SYSTEM can read that an operator must never be able to pull over the
|
||||
// wire: deployment answer files (local admin passwords), the agent's own
|
||||
// configuration and state, and key material. Checked against the normalized
|
||||
// typed path and again against the handle's final path.
|
||||
private static string? DeniedLogPathReason(string fullPath)
|
||||
{
|
||||
if (fullPath.StartsWith(@"\\", StringComparison.Ordinal))
|
||||
return "UNC/network paths are not supported";
|
||||
|
||||
var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
|
||||
var programData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.CommonApplicationData);
|
||||
var programFiles = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.ProgramFiles);
|
||||
var deniedRoots = new[]
|
||||
{
|
||||
Path.Combine(windows, "Panther"),
|
||||
Path.Combine(windows, "System32", "Sysprep"),
|
||||
Path.Combine(programData, "ShopDB"),
|
||||
Path.Combine(programFiles, "ShopDB", "EndpointAgent"),
|
||||
AppContext.BaseDirectory,
|
||||
};
|
||||
foreach (var root in deniedRoots)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root) || !Path.IsPathRooted(root))
|
||||
continue;
|
||||
var normalizedRoot = Path.GetFullPath(root).TrimEnd('\\');
|
||||
if (fullPath.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase)
|
||||
|| fullPath.StartsWith(normalizedRoot + "\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"Collecting files under {normalizedRoot} is not permitted";
|
||||
}
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(fullPath);
|
||||
var extension = Path.GetExtension(fileName);
|
||||
if ((fileName.StartsWith("unattend", StringComparison.OrdinalIgnoreCase)
|
||||
&& extension.Equals(".xml", StringComparison.OrdinalIgnoreCase))
|
||||
|| fileName.Equals("autounattend.xml", StringComparison.OrdinalIgnoreCase)
|
||||
|| (fileName.StartsWith("appsettings", StringComparison.OrdinalIgnoreCase)
|
||||
&& extension.Equals(".json", StringComparison.OrdinalIgnoreCase))
|
||||
|| extension.Equals(".pfx", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals(".p12", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals(".key", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"Collecting {fileName} is not permitted";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1043,7 +1120,7 @@ public sealed class CommandExecutor
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Could not kill timed-out schtasks.exe");
|
||||
logger.LogDebug(error, "Could not kill timed-out process tree");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,11 +1179,32 @@ public sealed class CommandExecutor
|
||||
}
|
||||
|
||||
var scriptUrl = CombineUrl(config.BaseUrl, script.InstallPath!);
|
||||
var tempPath = Path.Combine(GetStagingDirectory(),
|
||||
$"shopdb-script-{script.ScriptId}.ps1");
|
||||
var runInUser = string.Equals(
|
||||
script.RunContext, "user", StringComparison.OrdinalIgnoreCase);
|
||||
string? tempPath = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Resolved before anything is staged: a user-context script and its
|
||||
// transcript are granted to that one account and nobody else.
|
||||
SecurityIdentifier? userSid = null;
|
||||
if (runInUser)
|
||||
{
|
||||
userSid = InteractiveProcessLauncher.TryGetActiveUserSid(out var sidError);
|
||||
if (userSid is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"run_script for {Name} could not resolve the interactive user: {Error}",
|
||||
name, sidError);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} could not run in the user session: {sidError}",
|
||||
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
using var handler = new HttpClientHandler();
|
||||
using var client = new HttpClient(handler);
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
@@ -1125,15 +1223,22 @@ public sealed class CommandExecutor
|
||||
};
|
||||
}
|
||||
|
||||
await using (var source = await response.Content.ReadAsStreamAsync(token))
|
||||
await using (var file = File.Create(tempPath))
|
||||
// Held in memory, never on disk, until it is verified. A body on
|
||||
// disk before the check is a body that can be swapped under it.
|
||||
var body = await ReadBoundedAsync(response.Content, MaxScriptBytes, token);
|
||||
if (body is null)
|
||||
{
|
||||
await source.CopyToAsync(file, token);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"run_script download refused: script exceeds {MaxScriptBytes / (1024 * 1024)} MB",
|
||||
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(script.Sha256))
|
||||
{
|
||||
var actual = HashFile(tempPath);
|
||||
var actual = HashBytes(body);
|
||||
if (!actual.Equals(script.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning(
|
||||
@@ -1143,17 +1248,48 @@ public sealed class CommandExecutor
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "run_script integrity check failed: script SHA-256 does not match",
|
||||
Detail = ScriptDetail(name, script, tempPath, exitCode: null),
|
||||
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var runInUser = string.Equals(
|
||||
script.RunContext, "user", StringComparison.OrdinalIgnoreCase);
|
||||
ApplyErrorMode(script, tempPath, name);
|
||||
return runInUser
|
||||
? RunScriptInUserSession(name, script, tempPath)
|
||||
: await RunScriptAsSystem(name, script, tempPath, token);
|
||||
var staged = ApplyErrorMode(script, body, name);
|
||||
|
||||
var stagingDirectory = runInUser
|
||||
? ProtectedFileSystem.EnsureDirectory(UserScriptDirectoryName, userSid)
|
||||
: ProtectedFileSystem.EnsureDirectory(SystemStagingDirectoryName);
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
tempPath = Path.Combine(stagingDirectory,
|
||||
$"shopdb-script-{script.ScriptId}-{runId}.ps1");
|
||||
await using (var file = ProtectedFileSystem.CreateFile(
|
||||
tempPath, userSid, FileSystemRights.ReadAndExecute))
|
||||
{
|
||||
await file.WriteAsync(staged, token);
|
||||
}
|
||||
|
||||
// The bytes about to execute, not the bytes that were delivered:
|
||||
// after a strict-mode rewrite these differ, and both are pinned.
|
||||
var expectedStaged = HashBytes(staged);
|
||||
var actualStaged = HashFile(tempPath);
|
||||
if (!actualStaged.Equals(expectedStaged, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"run_script staged file for {Name} changed on disk: expected {Expected} got {Actual}",
|
||||
name, expectedStaged, actualStaged);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "run_script integrity check failed: staged script changed on disk",
|
||||
Detail = ScriptDetail(name, script, tempPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
if (!runInUser)
|
||||
return await RunScriptAsSystem(name, script, tempPath, token);
|
||||
|
||||
var transcriptPath = TryCreateTranscript(
|
||||
stagingDirectory, script.ScriptId, runId, userSid!, name);
|
||||
return RunScriptInUserSession(name, script, tempPath, transcriptPath);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -1173,8 +1309,8 @@ public sealed class CommandExecutor
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
File.Delete(tempPath);
|
||||
if (tempPath is not null)
|
||||
ProtectedFileSystem.DeleteFileNoFollow(tempPath);
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
@@ -1183,37 +1319,89 @@ public sealed class CommandExecutor
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort. A missing transcript directory must not stop the script from
|
||||
// running: the capture is diagnostics, and losing diagnostics is a smaller
|
||||
// failure than refusing to do the work that was asked for.
|
||||
private void TryEnsureTranscriptDirectory(string transcriptPath, string name)
|
||||
// Upper bound on a delivered script body. Scripts are text; anything near
|
||||
// this is not a script, and the body is held in memory until verified.
|
||||
private const int MaxScriptBytes = 8 * 1024 * 1024;
|
||||
|
||||
// Upper bound on how much of a user-session transcript is read back. Far
|
||||
// above what MaxCapturedOutputChars keeps, so the cap still reports truncation.
|
||||
private const int MaxTranscriptBytes = 256 * 1024;
|
||||
|
||||
// Subdirectories of %ProgramData%\ShopDB\EndpointAgent (Defender-excluded)
|
||||
// created with a protected DACL. Standard users can create files anywhere
|
||||
// else under ProgramData, so nothing SYSTEM executes or reads back is staged
|
||||
// outside these. userscripts additionally lets the target operator traverse
|
||||
// it, because a user-context run executes from there as that operator.
|
||||
private const string SystemStagingDirectoryName = "staging";
|
||||
private const string UserScriptDirectoryName = "userscripts";
|
||||
|
||||
private static async Task<byte[]?> ReadBoundedAsync(
|
||||
HttpContent content, int maxBytes, CancellationToken token)
|
||||
{
|
||||
if (content.Headers.ContentLength is long declared && declared > maxBytes)
|
||||
return null;
|
||||
|
||||
await using var source = await content.ReadAsStreamAsync(token);
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[81920];
|
||||
int read;
|
||||
while ((read = await source.ReadAsync(chunk, token)) > 0)
|
||||
{
|
||||
if (buffer.Length + read > maxBytes)
|
||||
return null;
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
// The transcript a user-session run redirects into. SYSTEM creates it
|
||||
// inside the protected directory and grants the operator read/write on this
|
||||
// one file, so the operator can fill it but cannot swap it for a link that
|
||||
// would steer the later SYSTEM read and delete. Best-effort: a run without
|
||||
// a transcript still runs, it just reports no output.
|
||||
private string? TryCreateTranscript(
|
||||
string stagingDirectory, int scriptId, string runId,
|
||||
SecurityIdentifier userSid, string name)
|
||||
{
|
||||
var transcriptPath = Path.Combine(stagingDirectory,
|
||||
$"shopdb-script-{scriptId}-{runId}-output.txt");
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(transcriptPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory!);
|
||||
using (ProtectedFileSystem.CreateFile(transcriptPath, userSid,
|
||||
FileSystemRights.Read | FileSystemRights.Write))
|
||||
{
|
||||
}
|
||||
return transcriptPath;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(
|
||||
error,
|
||||
"run_script {Name}: could not prepare the output transcript folder; "
|
||||
"run_script {Name}: could not prepare the output transcript; "
|
||||
+ "the script will still run but its output will not be captured", name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Read the transcript back and delete it. Deleted whether or not the read
|
||||
// worked: it sits in the operator's own profile where they could read it,
|
||||
// and a script's output is not theirs to keep lying around after the agent
|
||||
// has reported it.
|
||||
private string ReadAndClearTranscript(string transcriptPath, string name)
|
||||
// worked, so a script's output does not linger after it has been reported.
|
||||
// Never follows a reparse point for either operation.
|
||||
private string ReadAndClearTranscript(string? transcriptPath, string name)
|
||||
{
|
||||
if (transcriptPath is null)
|
||||
return "";
|
||||
|
||||
var output = "";
|
||||
try
|
||||
{
|
||||
if (File.Exists(transcriptPath))
|
||||
output = CombineScriptOutput(File.ReadAllText(transcriptPath), null);
|
||||
if (ProtectedFileSystem.IsReparsePoint(transcriptPath))
|
||||
logger.LogWarning(
|
||||
"run_script {Name}: output transcript {Path} is a reparse point; not read",
|
||||
name, transcriptPath);
|
||||
else if (ProtectedFileSystem.TryGetAttributes(transcriptPath) is not null)
|
||||
output = CombineScriptOutput(
|
||||
ProtectedFileSystem.ReadTextHeadNoFollow(transcriptPath, MaxTranscriptBytes),
|
||||
null);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
@@ -1222,8 +1410,7 @@ public sealed class CommandExecutor
|
||||
}
|
||||
try
|
||||
{
|
||||
if (File.Exists(transcriptPath))
|
||||
File.Delete(transcriptPath);
|
||||
ProtectedFileSystem.DeleteFileNoFollow(transcriptPath);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
@@ -1280,36 +1467,46 @@ public sealed class CommandExecutor
|
||||
name, exitCode?.ToString() ?? "unknown", Environment.NewLine, output);
|
||||
}
|
||||
|
||||
// Prepended to a strict-mode body. Written on ONE line so every line number
|
||||
// PowerShell reports still matches the body the author sees in the WebUI
|
||||
// editor: a multi-line prologue would shift them and make the error text
|
||||
// point at the wrong statement during exactly the debugging session that
|
||||
// needed it.
|
||||
// Inserted, followed by '; ', on the line of the first statement after any
|
||||
// using / #Requires / comment help / attribute / param header (see
|
||||
// StrictModePreamble). No newline is added, so line numbers PowerShell
|
||||
// reports still match the body the author sees in the WebUI editor; only
|
||||
// columns on that one line shift.
|
||||
private const string StrictModePrologue =
|
||||
"$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'";
|
||||
|
||||
// Turn a script's non-terminating errors fatal when the library says strict.
|
||||
//
|
||||
// MUST run AFTER the SHA-256 check. The digest pins the bytes the SERVER
|
||||
// holds; prepending first would rewrite the file and fail its own integrity
|
||||
// check. Verify what was delivered, then modify how it is interpreted.
|
||||
// holds; rewriting first would fail its own integrity check. Verify what was
|
||||
// delivered, then modify how it is interpreted; the rewritten bytes are
|
||||
// hashed again once staged.
|
||||
//
|
||||
// Best-effort by design: if the rewrite fails the script still runs under
|
||||
// PowerShell's default rules. That is the behaviour it had before this
|
||||
// existed, so a failed rewrite degrades to "as before" rather than blocking
|
||||
// work, and the warning says the verdict is the weaker one.
|
||||
private void ApplyErrorMode(ScriptDeliveryClaim script, string scriptPath, string name)
|
||||
// Best-effort by design: if the rewrite fails or the header cannot be read
|
||||
// with confidence, the script runs unmodified under PowerShell's default
|
||||
// rules. That is the behaviour it had before this existed, and the warning
|
||||
// says the verdict is the weaker one.
|
||||
private byte[] ApplyErrorMode(ScriptDeliveryClaim script, byte[] body, string name)
|
||||
{
|
||||
if (!string.Equals(script.ErrorMode, "strict", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
return body;
|
||||
|
||||
try
|
||||
{
|
||||
var body = File.ReadAllText(scriptPath);
|
||||
File.WriteAllText(scriptPath, StrictModePrologue + Environment.NewLine + body);
|
||||
var rewritten = StrictModePreamble.TryApply(body, StrictModePrologue, out var reason);
|
||||
if (rewritten is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"run_script {Name}: strict error mode NOT applied ({Reason}); running under "
|
||||
+ "PowerShell defaults, so a non-terminating error will NOT fail the command",
|
||||
name, reason);
|
||||
return body;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"run_script {Name}: strict error mode applied; a non-terminating "
|
||||
+ "error will now stop the script and fail the command", name);
|
||||
return rewritten;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
@@ -1318,6 +1515,7 @@ public sealed class CommandExecutor
|
||||
"run_script {Name}: could not apply strict error mode; running under "
|
||||
+ "PowerShell defaults, so a non-terminating error will NOT fail the command",
|
||||
name);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1356,6 +1554,12 @@ public sealed class CommandExecutor
|
||||
// System context: run through the agent's own service account and wait for
|
||||
// the exit code, the same as before. Fine for machine-scoped scripts that do
|
||||
// not need the interactive user's session resources.
|
||||
//
|
||||
// Both streams are drained WHILE the script runs. Reading only after exit
|
||||
// deadlocks as soon as a script fills a pipe buffer (it blocks on write, we
|
||||
// block on exit), and ReadToEnd would hold unbounded output in memory. The
|
||||
// run is bounded by SystemScriptTimeout; on expiry the whole process tree is
|
||||
// killed, since a SYSTEM PowerShell left running is worse than a failed run.
|
||||
private async Task<CommandResult> RunScriptAsSystem(
|
||||
string name, ScriptDeliveryClaim script, string scriptPath, CancellationToken token)
|
||||
{
|
||||
@@ -1399,14 +1603,46 @@ public sealed class CommandExecutor
|
||||
};
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(token);
|
||||
// Read only AFTER exit, for the reason spelled out on the schtasks path:
|
||||
// ReadToEnd blocks until the streams close, which IS process exit, so
|
||||
// reading first would make the caller's cancellation unable to fire.
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync(token);
|
||||
var stderr = await process.StandardError.ReadToEndAsync(token);
|
||||
var stdout = new BoundedCapture(MaxCapturedOutputChars + 1);
|
||||
var stderr = new BoundedCapture(MaxCapturedOutputChars + 1);
|
||||
var pumps = Task.WhenAll(
|
||||
stdout.PumpAsync(process.StandardOutput),
|
||||
stderr.PumpAsync(process.StandardError));
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
timeout.CancelAfter(SystemScriptTimeout);
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
TryKill(process);
|
||||
await DrainAsync(pumps);
|
||||
if (token.IsCancellationRequested)
|
||||
throw;
|
||||
|
||||
var partial = CombineScriptOutput(stdout.ToString(), stderr.ToString());
|
||||
LogScriptOutput(name, partial, exitCode: null);
|
||||
logger.LogWarning(
|
||||
"run_script {Name} did not finish within {Timeout}; process tree killed",
|
||||
name, SystemScriptTimeout);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} did not finish within "
|
||||
+ $"{SystemScriptTimeout.TotalMinutes:0} minutes; the script and its "
|
||||
+ "child processes were killed",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null,
|
||||
workingDirectory, partial),
|
||||
};
|
||||
}
|
||||
|
||||
// A grandchild that inherited the pipe can hold it open past exit, so
|
||||
// the final drain is bounded too.
|
||||
await DrainAsync(pumps);
|
||||
var exit = process.ExitCode;
|
||||
var output = CombineScriptOutput(stdout, stderr);
|
||||
var output = CombineScriptOutput(stdout.ToString(), stderr.ToString());
|
||||
LogScriptOutput(name, output, exit);
|
||||
return new CommandResult
|
||||
{
|
||||
@@ -1419,6 +1655,54 @@ public sealed class CommandExecutor
|
||||
};
|
||||
}
|
||||
|
||||
// The descriptor carries no per-script timeout, so SYSTEM runs get the same
|
||||
// budget as user-session runs.
|
||||
private static readonly TimeSpan SystemScriptTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
private static readonly TimeSpan StreamDrainTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private static async Task DrainAsync(Task pumps)
|
||||
=> await Task.WhenAny(pumps, Task.Delay(StreamDrainTimeout));
|
||||
|
||||
// Reads a redirected stream to EOF, keeping at most `limit` characters and
|
||||
// discarding the rest so the pipe never fills and memory stays bounded.
|
||||
private sealed class BoundedCapture
|
||||
{
|
||||
private readonly StringBuilder buffer = new();
|
||||
private readonly object gate = new();
|
||||
private readonly int limit;
|
||||
|
||||
public BoundedCapture(int limit) => this.limit = limit;
|
||||
|
||||
public Task PumpAsync(StreamReader reader) => Task.Run(async () =>
|
||||
{
|
||||
var chunk = new char[4096];
|
||||
try
|
||||
{
|
||||
int read;
|
||||
while ((read = await reader.ReadAsync(chunk, 0, chunk.Length)) > 0)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var room = limit - buffer.Length;
|
||||
if (room > 0)
|
||||
buffer.Append(chunk, 0, Math.Min(room, read));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Pipe torn down by a kill; keep what was captured.
|
||||
}
|
||||
});
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
lock (gate)
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// User context: launch PowerShell in the ACTIVE CONSOLE session so the script
|
||||
// runs at the interactive user's integrity and sees that user's resources
|
||||
// (mapped network drives, HKCU, profile). The launcher waits for the process
|
||||
@@ -1440,41 +1724,35 @@ public sealed class CommandExecutor
|
||||
// duplication). So PowerShell redirects to a file itself and the agent reads
|
||||
// it back after exit.
|
||||
//
|
||||
// The file lands in the OPERATOR's profile, not the agent's ProgramData
|
||||
// staging folder: staging is SYSTEM-owned and the signed-in operator cannot
|
||||
// write there, which is the same constraint that put the tray's own log in
|
||||
// the profile. Safe to rely on here because a user-context script only runs
|
||||
// when someone is interactively signed in, and a signed-in account has a
|
||||
// materialised profile by definition.
|
||||
private static string UserTranscriptPath(int scriptId)
|
||||
=> Path.Combine(
|
||||
FileLoggerProvider.UserProfileLogDirectory(),
|
||||
$"shopdb-script-{scriptId}-output.txt");
|
||||
|
||||
// The file is created by SYSTEM in the protected userscripts directory and
|
||||
// only that file is granted to the operator (see TryCreateTranscript). It
|
||||
// used to live in the operator's own profile, where the operator could
|
||||
// replace it with a junction or link and steer SYSTEM's read and delete.
|
||||
private CommandResult RunScriptInUserSession(
|
||||
string name, ScriptDeliveryClaim script, string scriptPath)
|
||||
string name, ScriptDeliveryClaim script, string scriptPath, string? transcriptPath)
|
||||
{
|
||||
var quotedPath = "\"" + scriptPath + "\"";
|
||||
// Redirect inside the user session rather than from the service. The
|
||||
// child is launched through CreateProcessAsUser, so the agent holds no
|
||||
// handles to its streams; -Command wraps the same -File invocation and
|
||||
// sends both streams to a file the operator can write, which the agent
|
||||
// reads back after exit. 2>&1 merges stderr into the same transcript
|
||||
// because ordering between the two matters more than telling them apart
|
||||
// when reconstructing what a script did.
|
||||
var transcriptPath = UserTranscriptPath(script.ScriptId);
|
||||
var quotedTranscript = "\"" + transcriptPath + "\"";
|
||||
TryEnsureTranscriptDirectory(transcriptPath, name);
|
||||
// sends every stream to the transcript the operator was granted, which
|
||||
// the agent reads back after exit. *> merges them because ordering
|
||||
// matters more than telling them apart when reconstructing what a
|
||||
// script did.
|
||||
var redirect = transcriptPath is null
|
||||
? ""
|
||||
: " *> " + ("\"" + transcriptPath + "\"").Replace("\"", "\\\"");
|
||||
var arguments =
|
||||
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "
|
||||
+ "\"& { & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy "
|
||||
+ "Bypass -File " + quotedPath.Replace("\"", "\\\"")
|
||||
+ " *> " + quotedTranscript.Replace("\"", "\\\"") + "; exit $LASTEXITCODE }\"";
|
||||
+ redirect + "; exit $LASTEXITCODE }\"";
|
||||
if (!TryResolveWorkingDirectory(
|
||||
script, scriptPath, out var workingDirectory, out var directoryError))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"run_script for {Name} not started: {Error}", name, directoryError);
|
||||
ReadAndClearTranscript(transcriptPath, name);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
@@ -1518,15 +1796,16 @@ public sealed class CommandExecutor
|
||||
};
|
||||
}
|
||||
|
||||
// Launched and waited on, yet no exit code: nothing proves it worked.
|
||||
var exit = outcome.ExitCode;
|
||||
var ok = exit is null || exit == 0;
|
||||
var ok = exit == 0;
|
||||
var output = ReadAndClearTranscript(transcriptPath, name);
|
||||
LogScriptOutput(name, output, exit);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = ok,
|
||||
Message = exit is null
|
||||
? $"Script {name} launched in the interactive user session"
|
||||
? $"Script {name} failed in the interactive user session: no exit code reported"
|
||||
: ok
|
||||
? $"Script {name} completed (user context)"
|
||||
: $"Script {name} exited with code {exit}",
|
||||
@@ -1617,11 +1896,16 @@ public sealed class CommandExecutor
|
||||
var packageUrl = CombineUrl(config.BaseUrl, app.InstallPath!);
|
||||
var ext = Path.GetExtension(app.InstallPath) ?? "";
|
||||
var packageKey = app.AppVersionId?.ToString() ?? $"app{app.AppId}";
|
||||
var tempPath = Path.Combine(GetStagingDirectory(),
|
||||
$"shopdb-app-{packageKey}{ext}");
|
||||
string? tempPath = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Protected directory and a random name, so no standard user can
|
||||
// pre-place or swap the package between the hash check and msiexec.
|
||||
tempPath = Path.Combine(
|
||||
ProtectedFileSystem.EnsureDirectory(SystemStagingDirectoryName),
|
||||
$"shopdb-app-{packageKey}-{Guid.NewGuid():N}{ext}");
|
||||
|
||||
using var handler = new HttpClientHandler();
|
||||
using var client = new HttpClient(handler);
|
||||
client.Timeout = TimeSpan.FromMinutes(10);
|
||||
@@ -1641,7 +1925,7 @@ public sealed class CommandExecutor
|
||||
}
|
||||
|
||||
await using (var source = await response.Content.ReadAsStreamAsync(token))
|
||||
await using (var file = File.Create(tempPath))
|
||||
await using (var file = ProtectedFileSystem.CreateFile(tempPath))
|
||||
{
|
||||
await source.CopyToAsync(file, token);
|
||||
}
|
||||
@@ -1716,8 +2000,8 @@ public sealed class CommandExecutor
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
File.Delete(tempPath);
|
||||
if (tempPath is not null)
|
||||
ProtectedFileSystem.DeleteFileNoFollow(tempPath);
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
@@ -1743,28 +2027,17 @@ public sealed class CommandExecutor
|
||||
return (packagePath, extra);
|
||||
}
|
||||
|
||||
// Fixed, known staging folder we control and that is Defender-excluded in
|
||||
// the environment. LocalSystem's Path.GetTempPath() resolves to
|
||||
// C:\Windows\SystemTemp on Win11/Server2022+, which we do not use; stage
|
||||
// downloads here instead so run_script and update_app land in a predictable
|
||||
// spot. Created on demand.
|
||||
private static string GetStagingDirectory()
|
||||
{
|
||||
var programData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.CommonApplicationData);
|
||||
var staging = Path.Combine(programData, "ShopDB", "EndpointAgent");
|
||||
Directory.CreateDirectory(staging);
|
||||
return staging;
|
||||
}
|
||||
|
||||
private static string HashFile(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var stream = ProtectedFileSystem.OpenReadNoFollow(path);
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string HashBytes(byte[] bytes)
|
||||
=> Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
// Join the agent base URL to a ShopDB-relative package path, tolerating a
|
||||
// trailing slash on the base and a leading slash on the path.
|
||||
private static string CombineUrl(string baseUrl, string path)
|
||||
|
||||
@@ -76,8 +76,14 @@ public sealed class FleetAlertClient : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
// No pinning configured: accept all (insecure, for rollout compatibility)
|
||||
return true;
|
||||
// No pinning configured: normal Windows chain validation
|
||||
if (errors == System.Net.Security.SslPolicyErrors.None)
|
||||
return true;
|
||||
|
||||
logger.LogWarning(
|
||||
"Server certificate rejected on fleet alert channel: {Errors} ({Subject})",
|
||||
errors, cert.Subject);
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -158,18 +164,78 @@ public sealed class FleetAlertClient : IDisposable
|
||||
public void Dispose() => http.Dispose();
|
||||
}
|
||||
|
||||
// Server-sent strings may be JSON null; setters coerce to "" so consumers
|
||||
// never see null. Timeout null or <= 0 means no timeout.
|
||||
public sealed class FleetAlert
|
||||
{
|
||||
private string typeName = "";
|
||||
private string typeColor = "";
|
||||
private string title = "";
|
||||
private string message = "";
|
||||
|
||||
[JsonPropertyName("notificationId")] public int NotificationId { get; set; }
|
||||
[JsonPropertyName("type")] public string TypeName { get; set; } = "";
|
||||
[JsonPropertyName("typeColor")] public string TypeColor { get; set; } = "";
|
||||
[JsonPropertyName("title")] public string Title { get; set; } = "";
|
||||
[JsonPropertyName("message")] public string Message { get; set; } = "";
|
||||
[JsonPropertyName("timeout")] public int Timeout { get; set; }
|
||||
[JsonPropertyName("type")] public string TypeName { get => typeName; set => typeName = value ?? ""; }
|
||||
[JsonPropertyName("typeColor")] public string TypeColor { get => typeColor; set => typeColor = value ?? ""; }
|
||||
[JsonPropertyName("title")] public string Title { get => title; set => title = value ?? ""; }
|
||||
[JsonPropertyName("message")] public string Message { get => message; set => message = value ?? ""; }
|
||||
[JsonPropertyName("timeout")] public int? Timeout { get; set; }
|
||||
|
||||
[JsonIgnore] public int TimeoutMinutes => Timeout is > 0 ? Timeout.Value : 0;
|
||||
}
|
||||
|
||||
public sealed class FleetAlertEnvelope
|
||||
{
|
||||
[JsonPropertyName("status")] public string Status { get; set; } = "";
|
||||
[JsonPropertyName("data")] public List<FleetAlert>? Data { get; set; }
|
||||
|
||||
[JsonPropertyName("data")]
|
||||
[JsonConverter(typeof(TolerantFleetAlertListConverter))]
|
||||
public List<FleetAlert>? Data { get; set; }
|
||||
}
|
||||
|
||||
// List of alerts that parsed, plus a note per element that did not. The
|
||||
// caller logs Skipped; one malformed alert must not drop the rest.
|
||||
public sealed class FleetAlertList : List<FleetAlert>
|
||||
{
|
||||
public List<string> Skipped { get; } = new();
|
||||
}
|
||||
|
||||
// Deserializes the data array one element at a time. An element that fails
|
||||
// to bind, or has no usable notificationId, is recorded and skipped.
|
||||
public sealed class TolerantFleetAlertListConverter : JsonConverter<List<FleetAlert>>
|
||||
{
|
||||
public override List<FleetAlert>? Read(
|
||||
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
return null;
|
||||
if (reader.TokenType != JsonTokenType.StartArray)
|
||||
throw new JsonException("Fleet alert data is not an array.");
|
||||
|
||||
var list = new FleetAlertList();
|
||||
var index = 0;
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
|
||||
{
|
||||
using var element = JsonDocument.ParseValue(ref reader);
|
||||
try
|
||||
{
|
||||
var alert = element.RootElement.Deserialize<FleetAlert>(options);
|
||||
if (alert is null || alert.NotificationId <= 0)
|
||||
list.Skipped.Add($"element {index}: missing notificationId");
|
||||
else
|
||||
list.Add(alert);
|
||||
}
|
||||
catch (Exception exception) when (exception is JsonException or InvalidOperationException)
|
||||
{
|
||||
list.Skipped.Add($"element {index}: {exception.Message}");
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public override void Write(
|
||||
Utf8JsonWriter writer, List<FleetAlert> value, JsonSerializerOptions options)
|
||||
{
|
||||
JsonSerializer.Serialize(writer, value.ToArray(), options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
@@ -236,6 +237,42 @@ public static class InteractiveProcessLauncher
|
||||
}
|
||||
}
|
||||
|
||||
// SID of the active console user, so SYSTEM can grant that one account
|
||||
// access to a file it stages. Null with a reason when nobody is signed in.
|
||||
public static SecurityIdentifier? TryGetActiveUserSid(out string? error)
|
||||
{
|
||||
var sessionId = WTSGetActiveConsoleSessionId();
|
||||
if (sessionId == InvalidSessionId)
|
||||
{
|
||||
error = "No active console session (no interactive user is signed in).";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!WTSQueryUserToken(sessionId, out var userToken))
|
||||
{
|
||||
error = "Could not obtain the interactive user token (WTSQueryUserToken failed): "
|
||||
+ new Win32Exception(Marshal.GetLastWin32Error()).Message;
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var identity = new WindowsIdentity(userToken);
|
||||
error = identity.User is null ? "Interactive user token carries no SID." : null;
|
||||
return identity.User;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
error = "Could not read the interactive user SID: " + exception.Message;
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (userToken != IntPtr.Zero)
|
||||
CloseHandle(userToken);
|
||||
}
|
||||
}
|
||||
|
||||
private enum SecurityImpersonationLevel
|
||||
{
|
||||
SecurityAnonymous,
|
||||
|
||||
285
agent/ShopDbEndpointAgent.Core/ProtectedFileSystem.cs
Normal file
285
agent/ShopDbEndpointAgent.Core/ProtectedFileSystem.cs
Normal file
@@ -0,0 +1,285 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// File access for paths the agent touches AS SYSTEM where a standard user could
|
||||
// otherwise interfere.
|
||||
//
|
||||
// %ProgramData% lets any user create files and folders beneath it, so a staging
|
||||
// path the agent does not lock down can be pre-created, swapped between the hash
|
||||
// check and execution, or replaced with a junction that redirects a SYSTEM read
|
||||
// or delete to an arbitrary file. Directories made here carry a protected DACL:
|
||||
// SYSTEM and BUILTIN\Administrators only, inheritance off. SIDs are well-known
|
||||
// strings so a localized OS resolves them the same way.
|
||||
internal static class ProtectedFileSystem
|
||||
{
|
||||
private static readonly SecurityIdentifier LocalSystemSid = new("S-1-5-18");
|
||||
private static readonly SecurityIdentifier AdministratorsSid = new("S-1-5-32-544");
|
||||
|
||||
private const uint GenericRead = 0x80000000;
|
||||
private const uint FileShareRead = 0x00000001;
|
||||
private const uint FileShareWrite = 0x00000002;
|
||||
private const uint FileShareDelete = 0x00000004;
|
||||
private const uint OpenExisting = 3;
|
||||
private const uint FileFlagOpenReparsePoint = 0x00200000;
|
||||
|
||||
// %ProgramData%\ShopDB\EndpointAgent. The Defender-excluded agent root.
|
||||
public static string AgentDataRoot => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"ShopDB", "EndpointAgent");
|
||||
|
||||
// Create or re-secure <AgentDataRoot>\<name>. readerSid, when set, gets
|
||||
// list/traverse on the directory itself (not inherited) so a process in that
|
||||
// user's session can run from it and open files it was granted explicitly.
|
||||
// Throws when the directory cannot be proven safe; callers fail closed.
|
||||
public static string EnsureDirectory(string name, SecurityIdentifier? readerSid = null)
|
||||
{
|
||||
var root = AgentDataRoot;
|
||||
Directory.CreateDirectory(root);
|
||||
RefuseReparseChain(root);
|
||||
|
||||
var path = Path.Combine(root, name);
|
||||
var attributes = TryGetAttributes(path);
|
||||
if (attributes is FileAttributes existing
|
||||
&& (existing.HasFlag(FileAttributes.ReparsePoint)
|
||||
|| !existing.HasFlag(FileAttributes.Directory)))
|
||||
{
|
||||
// Link or planted file at our name. Removing a link never touches
|
||||
// its target.
|
||||
if (existing.HasFlag(FileAttributes.Directory))
|
||||
Directory.Delete(path);
|
||||
else
|
||||
File.Delete(path);
|
||||
attributes = null;
|
||||
}
|
||||
|
||||
if (attributes is null)
|
||||
new DirectoryInfo(path).Create(DirectorySecurityFor(readerSid));
|
||||
|
||||
var info = new DirectoryInfo(path);
|
||||
if (!IsTrustedOwner(info))
|
||||
{
|
||||
// Pre-created by someone else. The owner can always rewrite the
|
||||
// DACL, so re-securing is not enough: replace the directory.
|
||||
Directory.Delete(path, recursive: true);
|
||||
new DirectoryInfo(path).Create(DirectorySecurityFor(readerSid));
|
||||
info = new DirectoryInfo(path);
|
||||
if (!IsTrustedOwner(info))
|
||||
throw new IOException($"Staging directory {path} has an untrusted owner");
|
||||
}
|
||||
|
||||
if (IsReparsePoint(path))
|
||||
throw new IOException($"Staging directory {path} is a reparse point");
|
||||
|
||||
info.SetAccessControl(DirectorySecurityFor(readerSid));
|
||||
return path;
|
||||
}
|
||||
|
||||
// New file inside a protected directory. Any existing entry at the name is
|
||||
// removed first, then CreateNew so nothing can be slipped in between. The
|
||||
// DACL is protected; userSid, when set, is granted userRights explicitly.
|
||||
public static FileStream CreateFile(
|
||||
string path, SecurityIdentifier? userSid = null,
|
||||
FileSystemRights userRights = FileSystemRights.ReadAndExecute)
|
||||
{
|
||||
if (TryGetAttributes(path) is not null)
|
||||
File.Delete(path);
|
||||
|
||||
var security = new FileSecurity();
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
LocalSystemSid, FileSystemRights.FullControl, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
AdministratorsSid, FileSystemRights.FullControl, AccessControlType.Allow));
|
||||
if (userSid is not null)
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
userSid, userRights, AccessControlType.Allow));
|
||||
|
||||
return new FileInfo(path).Create(
|
||||
FileMode.CreateNew, FileSystemRights.Read | FileSystemRights.Write,
|
||||
FileShare.Read, 4096, FileOptions.None, security);
|
||||
}
|
||||
|
||||
// Attributes of the entry itself (a link reports its own, not its target's).
|
||||
public static FileAttributes? TryGetAttributes(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.GetAttributes(path);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsReparsePoint(string path)
|
||||
=> TryGetAttributes(path) is FileAttributes attributes
|
||||
&& attributes.HasFlag(FileAttributes.ReparsePoint);
|
||||
|
||||
// Open for reading without following a reparse point at the final
|
||||
// component, and refuse if the opened object is one. Share mode is wide so
|
||||
// files a running application holds open still read.
|
||||
public static FileStream OpenReadNoFollow(string path)
|
||||
{
|
||||
var handle = CreateFileW(path, GenericRead,
|
||||
FileShareRead | FileShareWrite | FileShareDelete, IntPtr.Zero,
|
||||
OpenExisting, FileFlagOpenReparsePoint, IntPtr.Zero);
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
var hr = Marshal.GetHRForLastWin32Error();
|
||||
handle.Dispose();
|
||||
throw Marshal.GetExceptionForHR(hr) ?? new IOException($"Could not open {path}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!GetFileInformationByHandle(handle, out var information))
|
||||
throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error())
|
||||
?? new IOException($"Could not query {path}");
|
||||
|
||||
var attributes = (FileAttributes)information.FileAttributes;
|
||||
if (attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
throw new UnauthorizedAccessException($"{path} is a reparse point");
|
||||
if (attributes.HasFlag(FileAttributes.Directory))
|
||||
throw new UnauthorizedAccessException($"{path} is a directory");
|
||||
|
||||
return new FileStream(handle, FileAccess.Read);
|
||||
}
|
||||
catch
|
||||
{
|
||||
handle.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// The path the handle really refers to after every junction and link in
|
||||
// its ancestry is resolved. Null when it cannot be read.
|
||||
public static string? FinalPath(SafeFileHandle handle)
|
||||
{
|
||||
var buffer = new StringBuilder(1024);
|
||||
var length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
|
||||
if (length == 0)
|
||||
return null;
|
||||
if (length >= buffer.Capacity)
|
||||
{
|
||||
buffer = new StringBuilder((int)length + 1);
|
||||
length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
|
||||
if (length == 0 || length >= buffer.Capacity)
|
||||
return null;
|
||||
}
|
||||
|
||||
var final = buffer.ToString();
|
||||
if (final.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
|
||||
return @"\\" + final[8..];
|
||||
if (final.StartsWith(@"\\?\", StringComparison.Ordinal))
|
||||
return final[4..];
|
||||
return final;
|
||||
}
|
||||
|
||||
// Read at most maxBytes of text from the head of a file, BOM-aware.
|
||||
public static string ReadTextHeadNoFollow(string path, int maxBytes)
|
||||
{
|
||||
using var stream = OpenReadNoFollow(path);
|
||||
var buffer = new byte[(int)Math.Min(maxBytes, Math.Max(stream.Length, 0))];
|
||||
var total = 0;
|
||||
int read;
|
||||
while (total < buffer.Length
|
||||
&& (read = stream.Read(buffer, total, buffer.Length - total)) > 0)
|
||||
total += read;
|
||||
|
||||
using var reader = new StreamReader(
|
||||
new MemoryStream(buffer, 0, total), Encoding.UTF8, true);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
// Delete the entry itself. DeleteFile removes a link, never its target;
|
||||
// a directory at a file path is left alone.
|
||||
public static void DeleteFileNoFollow(string path)
|
||||
{
|
||||
if (TryGetAttributes(path) is not FileAttributes attributes)
|
||||
return;
|
||||
if (attributes.HasFlag(FileAttributes.Directory))
|
||||
throw new IOException($"{path} is a directory, not a file");
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
private static DirectorySecurity DirectorySecurityFor(SecurityIdentifier? readerSid)
|
||||
{
|
||||
var security = new DirectorySecurity();
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
const InheritanceFlags inherit =
|
||||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
|
||||
security.AddAccessRule(new FileSystemAccessRule(LocalSystemSid,
|
||||
FileSystemRights.FullControl, inherit, PropagationFlags.None,
|
||||
AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(AdministratorsSid,
|
||||
FileSystemRights.FullControl, inherit, PropagationFlags.None,
|
||||
AccessControlType.Allow));
|
||||
if (readerSid is not null)
|
||||
security.AddAccessRule(new FileSystemAccessRule(readerSid,
|
||||
FileSystemRights.ReadAndExecute, InheritanceFlags.None,
|
||||
PropagationFlags.None, AccessControlType.Allow));
|
||||
return security;
|
||||
}
|
||||
|
||||
private static bool IsTrustedOwner(DirectoryInfo info)
|
||||
{
|
||||
var owner = info.GetAccessControl(AccessControlSections.Owner)
|
||||
.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier;
|
||||
return owner is not null
|
||||
&& (owner.Equals(LocalSystemSid) || owner.Equals(AdministratorsSid));
|
||||
}
|
||||
|
||||
// A junction anywhere from ProgramData down to the agent root would move
|
||||
// every "protected" directory somewhere else.
|
||||
private static void RefuseReparseChain(string root)
|
||||
{
|
||||
var programData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.CommonApplicationData).TrimEnd('\\');
|
||||
var current = Path.GetFullPath(root).TrimEnd('\\');
|
||||
while (current.Length > programData.Length
|
||||
&& current.StartsWith(programData, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (IsReparsePoint(current))
|
||||
throw new IOException($"{current} is a reparse point; refusing to stage under it");
|
||||
current = Path.GetDirectoryName(current) ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct ByHandleFileInformation
|
||||
{
|
||||
public uint FileAttributes;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
|
||||
public uint VolumeSerialNumber;
|
||||
public uint FileSizeHigh;
|
||||
public uint FileSizeLow;
|
||||
public uint NumberOfLinks;
|
||||
public uint FileIndexHigh;
|
||||
public uint FileIndexLow;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern SafeFileHandle CreateFileW(
|
||||
string fileName, uint desiredAccess, uint shareMode, IntPtr securityAttributes,
|
||||
uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GetFileInformationByHandle(
|
||||
SafeFileHandle file, out ByHandleFileInformation information);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern uint GetFinalPathNameByHandleW(
|
||||
SafeFileHandle file, StringBuilder filePath, uint filePathLength, uint flags);
|
||||
}
|
||||
293
agent/ShopDbEndpointAgent.Core/StrictModePreamble.cs
Normal file
293
agent/ShopDbEndpointAgent.Core/StrictModePreamble.cs
Normal file
@@ -0,0 +1,293 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Inserts the strict error-mode statement into a PowerShell script body.
|
||||
//
|
||||
// The statement cannot simply go first: using statements, [CmdletBinding()] and
|
||||
// param(...) must precede every other statement or the script stops parsing.
|
||||
// So the scanner walks the leading header (whitespace, # and <# #> comments
|
||||
// including #Requires and comment help, using lines, attributes, the param
|
||||
// block) and inserts on the SAME line as the first real statement, followed by
|
||||
// '; ', so every line number PowerShell reports still matches the stored body.
|
||||
// Anything it cannot read with confidence (named begin/process/end blocks,
|
||||
// subexpressions or here-strings inside the header, unbalanced brackets, bytes
|
||||
// that are not valid text) returns null and the caller skips injection.
|
||||
internal static class StrictModePreamble
|
||||
{
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||
private static readonly UTF8Encoding Utf8WithBom = new(true);
|
||||
|
||||
private static readonly string[] NamedBlocks =
|
||||
{ "begin", "process", "end", "dynamicparam", "clean" };
|
||||
|
||||
// Returns the rewritten body as UTF-8 WITH BOM (Windows PowerShell 5.1
|
||||
// reads a BOM-less file as the ANSI code page), or null with a reason.
|
||||
public static byte[]? TryApply(byte[] body, string statement, out string? reason)
|
||||
{
|
||||
if (!TryDecode(body, out var text))
|
||||
{
|
||||
reason = "script is not valid UTF-8 or UTF-16 text";
|
||||
return null;
|
||||
}
|
||||
|
||||
var index = FindInsertionPoint(text, out reason);
|
||||
if (index < 0)
|
||||
return null;
|
||||
|
||||
var rewritten = text.Insert(index, statement + "; ");
|
||||
var preamble = Utf8WithBom.GetPreamble();
|
||||
var encoded = Utf8WithBom.GetBytes(rewritten);
|
||||
var result = new byte[preamble.Length + encoded.Length];
|
||||
preamble.CopyTo(result, 0);
|
||||
encoded.CopyTo(result, preamble.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool TryDecode(byte[] body, out string text)
|
||||
{
|
||||
text = "";
|
||||
try
|
||||
{
|
||||
if (body.Length >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF)
|
||||
text = StrictUtf8.GetString(body, 3, body.Length - 3);
|
||||
else if (body.Length >= 2 && body[0] == 0xFF && body[1] == 0xFE)
|
||||
text = new UnicodeEncoding(false, false, true).GetString(body, 2, body.Length - 2);
|
||||
else if (body.Length >= 2 && body[0] == 0xFE && body[1] == 0xFF)
|
||||
text = new UnicodeEncoding(true, false, true).GetString(body, 2, body.Length - 2);
|
||||
else
|
||||
text = StrictUtf8.GetString(body);
|
||||
return true;
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Index of the first statement after the script header, or -1.
|
||||
internal static int FindInsertionPoint(string text, out string? reason)
|
||||
{
|
||||
reason = null;
|
||||
var index = SkipTrivia(text, 0);
|
||||
while (index >= 0 && MatchWord(text, index, "using"))
|
||||
{
|
||||
var lineEnd = text.IndexOf('\n', index);
|
||||
index = SkipTrivia(text, lineEnd < 0 ? text.Length : lineEnd);
|
||||
}
|
||||
if (index < 0)
|
||||
return Uncertain("unterminated block comment in header", out reason);
|
||||
|
||||
var cursor = index;
|
||||
while (cursor < text.Length && text[cursor] == '[')
|
||||
{
|
||||
cursor = SkipBalanced(text, cursor);
|
||||
if (cursor < 0)
|
||||
return Uncertain("could not read attribute in header", out reason);
|
||||
cursor = SkipTrivia(text, cursor);
|
||||
if (cursor < 0)
|
||||
return Uncertain("unterminated block comment in header", out reason);
|
||||
}
|
||||
|
||||
if (MatchWord(text, cursor, "param"))
|
||||
{
|
||||
var open = SkipTrivia(text, cursor + "param".Length);
|
||||
if (open < 0 || open >= text.Length || text[open] != '(')
|
||||
return Uncertain("could not read param block", out reason);
|
||||
var close = SkipBalanced(text, open);
|
||||
if (close < 0)
|
||||
return Uncertain("could not read param block", out reason);
|
||||
index = SkipTrivia(text, close);
|
||||
if (index < 0)
|
||||
return Uncertain("unterminated block comment after param block", out reason);
|
||||
}
|
||||
// Brackets not followed by param are a type literal starting the first
|
||||
// statement, so index stays in front of them.
|
||||
|
||||
if (index >= text.Length)
|
||||
return Uncertain("no statements after the script header", out reason);
|
||||
|
||||
foreach (var block in NamedBlocks)
|
||||
{
|
||||
if (MatchWord(text, index, block))
|
||||
return Uncertain($"script uses a named '{block}' block", out reason);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static int Uncertain(string why, out string? reason)
|
||||
{
|
||||
reason = why;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Skip whitespace, line comments and block comments. -1 on an unterminated
|
||||
// block comment.
|
||||
private static int SkipTrivia(string text, int index)
|
||||
{
|
||||
while (index < text.Length)
|
||||
{
|
||||
var character = text[index];
|
||||
if (char.IsWhiteSpace(character) || character == '\uFEFF')
|
||||
{
|
||||
index++;
|
||||
}
|
||||
else if (character == '<' && index + 1 < text.Length && text[index + 1] == '#')
|
||||
{
|
||||
var end = text.IndexOf("#>", index + 2, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
return -1;
|
||||
index = end + 2;
|
||||
}
|
||||
else if (character == '#')
|
||||
{
|
||||
var lineEnd = text.IndexOf('\n', index);
|
||||
index = lineEnd < 0 ? text.Length : lineEnd + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
private static bool MatchWord(string text, int index, string word)
|
||||
{
|
||||
if (index < 0 || index + word.Length > text.Length)
|
||||
return false;
|
||||
if (string.Compare(text, index, word, 0, word.Length, StringComparison.OrdinalIgnoreCase) != 0)
|
||||
return false;
|
||||
var after = index + word.Length;
|
||||
if (after == text.Length)
|
||||
return true;
|
||||
var next = text[after];
|
||||
return !(char.IsLetterOrDigit(next) || next == '_' || next == '-');
|
||||
}
|
||||
|
||||
private static bool IsSingleQuote(char character)
|
||||
=> character is '\'' or '\u2018' or '\u2019' or '\u201A' or '\u201B';
|
||||
|
||||
private static bool IsDoubleQuote(char character)
|
||||
=> character is '"' or '\u201C' or '\u201D' or '\u201E';
|
||||
|
||||
// From an opening ( [ or { return the index just past its matching closer,
|
||||
// honoring quotes and comments. -1 when unsure.
|
||||
private static int SkipBalanced(string text, int index)
|
||||
{
|
||||
var closers = new Stack<char>();
|
||||
while (index < text.Length)
|
||||
{
|
||||
var character = text[index];
|
||||
switch (character)
|
||||
{
|
||||
case '(':
|
||||
closers.Push(')');
|
||||
index++;
|
||||
break;
|
||||
case '[':
|
||||
closers.Push(']');
|
||||
index++;
|
||||
break;
|
||||
case '{':
|
||||
closers.Push('}');
|
||||
index++;
|
||||
break;
|
||||
case ')':
|
||||
case ']':
|
||||
case '}':
|
||||
if (closers.Count == 0 || closers.Pop() != character)
|
||||
return -1;
|
||||
index++;
|
||||
if (closers.Count == 0)
|
||||
return index;
|
||||
break;
|
||||
case '`':
|
||||
index += 2;
|
||||
break;
|
||||
case '@' when index + 1 < text.Length
|
||||
&& (IsSingleQuote(text[index + 1]) || IsDoubleQuote(text[index + 1])):
|
||||
// Here-string; not worth reading in a header.
|
||||
return -1;
|
||||
case '<' when index + 1 < text.Length && text[index + 1] == '#':
|
||||
var end = text.IndexOf("#>", index + 2, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
return -1;
|
||||
index = end + 2;
|
||||
break;
|
||||
case '#':
|
||||
if (index > 0 && (char.IsLetterOrDigit(text[index - 1]) || text[index - 1] == '_'))
|
||||
return -1;
|
||||
var lineEnd = text.IndexOf('\n', index);
|
||||
if (lineEnd < 0)
|
||||
return -1;
|
||||
index = lineEnd + 1;
|
||||
break;
|
||||
default:
|
||||
if (IsSingleQuote(character))
|
||||
{
|
||||
index = SkipSingleQuoted(text, index + 1);
|
||||
if (index < 0)
|
||||
return -1;
|
||||
}
|
||||
else if (IsDoubleQuote(character))
|
||||
{
|
||||
index = SkipDoubleQuoted(text, index + 1);
|
||||
if (index < 0)
|
||||
return -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
index++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int SkipSingleQuoted(string text, int index)
|
||||
{
|
||||
while (index < text.Length)
|
||||
{
|
||||
if (IsSingleQuote(text[index]))
|
||||
{
|
||||
if (index + 1 < text.Length && IsSingleQuote(text[index + 1]))
|
||||
{
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
return index + 1;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int SkipDoubleQuoted(string text, int index)
|
||||
{
|
||||
while (index < text.Length)
|
||||
{
|
||||
var character = text[index];
|
||||
if (character == '`')
|
||||
{
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (character == '$' && index + 1 < text.Length && text[index + 1] == '(')
|
||||
return -1;
|
||||
if (IsDoubleQuote(character))
|
||||
{
|
||||
if (index + 1 < text.Length && IsDoubleQuote(text[index + 1]))
|
||||
{
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
return index + 1;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -160,8 +160,7 @@ try
|
||||
if (!string.IsNullOrWhiteSpace(reportPayloadPath))
|
||||
return await RunReportAsync(config, reportPayloadPath, logDirectory, faultMonitor);
|
||||
|
||||
await RunHostAsync(config, agentVersion, logDirectory, forceConsole);
|
||||
return 0;
|
||||
return await RunHostAsync(config, agentVersion, logDirectory, forceConsole);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
@@ -169,11 +168,20 @@ catch (Exception error)
|
||||
return 1;
|
||||
}
|
||||
|
||||
static async Task RunHostAsync(AgentConfig config, string agentVersion,
|
||||
static async Task<int> RunHostAsync(AgentConfig config, string agentVersion,
|
||||
string logDirectory, bool forceConsole)
|
||||
{
|
||||
// Non-zero on purpose: SCM recovery only restarts a service that exits with
|
||||
// a failure. Exit 0 after a renewal left the agent stopped until reboot.
|
||||
const int RestartForRenewedCertificate = 3;
|
||||
|
||||
// Well inside the SCM 30s start timeout. The host has not reported
|
||||
// SERVICE_RUNNING yet, so a slow or unreachable server here would otherwise
|
||||
// get the whole start killed.
|
||||
var renewalBudget = TimeSpan.FromSeconds(15);
|
||||
|
||||
// Certificate renewal check: if client cert expires within 30 days, request
|
||||
// renewal from server, install new cert, and gracefully stop so service manager
|
||||
// renewal from server, install new cert, and exit so service manager
|
||||
// restarts us. This runs BEFORE the host starts so a renewed cert is picked up.
|
||||
var renewalLogger = LoggerFactory.Create(logging =>
|
||||
ConfigureLogging(logging, logDirectory)).CreateLogger<CertificateRenewal>();
|
||||
@@ -181,18 +189,25 @@ static async Task RunHostAsync(AgentConfig config, string agentVersion,
|
||||
|
||||
try
|
||||
{
|
||||
var didRenew = await renewal.CheckAndRenewIfNeeded();
|
||||
if (didRenew)
|
||||
// Task.Run so a slow synchronous prefix cannot dodge the budget either.
|
||||
var renewalTask = Task.Run(renewal.CheckAndRenewIfNeeded);
|
||||
if (await Task.WhenAny(renewalTask, Task.Delay(renewalBudget)) != renewalTask)
|
||||
{
|
||||
renewalLogger.LogWarning(
|
||||
"Certificate renewal check still running after {Budget}; starting agent " +
|
||||
"with existing certificate. The expiry monitor retries later.",
|
||||
renewalBudget);
|
||||
}
|
||||
else if (await renewalTask)
|
||||
{
|
||||
renewalLogger.LogInformation(
|
||||
"Certificate renewed successfully. Service will exit gracefully " +
|
||||
"and service manager will restart with new certificate.");
|
||||
// Return immediately without starting the host. The service exits gracefully
|
||||
// with code 0, and Windows Service Manager restarts it (if configured as
|
||||
// Automatic). The new process picks up the updated thumbprint from
|
||||
// appsettings.json. This is safer than Environment.Exit(0) which aborts
|
||||
// in-flight work and can corrupt local state.
|
||||
return;
|
||||
"Certificate renewed successfully. Service will exit with code {Code} " +
|
||||
"so service manager recovery restarts it with the new certificate.",
|
||||
RestartForRenewedCertificate);
|
||||
// Return without starting the host. The new process picks up the
|
||||
// updated thumbprint from appsettings.json. Safer than
|
||||
// Environment.Exit, which aborts in-flight work.
|
||||
return RestartForRenewedCertificate;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -221,6 +236,7 @@ static async Task RunHostAsync(AgentConfig config, string agentVersion,
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<CertificateExpiryMonitor>());
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> RunOnceAsync(AgentConfig config, string agentVersion,
|
||||
|
||||
@@ -248,7 +248,24 @@
|
||||
delete-all-insert-all to delta mode (only touch changed apps). Expected
|
||||
impact: 99% reduction in database writes (1.92M/day to 19K/day). Matches
|
||||
backend algorithm implemented in 1.17.0. Minor for feature completion. -->
|
||||
<Version>1.18.0</Version>
|
||||
<!-- 1.19.0: endpoint hardening. TLS without a pinned thumbprint now uses
|
||||
normal Windows chain validation instead of accepting any certificate.
|
||||
run_script and update_app stage into SYSTEM/Administrators-only
|
||||
directories under %ProgramData%\ShopDB\EndpointAgent with random file
|
||||
names, and the script hash is checked in memory and again over the
|
||||
exact bytes written, so a standard user can no longer swap a body
|
||||
between verification and execution. User-session transcripts moved out
|
||||
of the operator's profile into that protected area, and SYSTEM never
|
||||
follows a reparse point to read or delete them. SYSTEM scripts drain
|
||||
stdout/stderr while running (no pipe deadlock), cap captured output,
|
||||
and are killed with their process tree after 5 minutes. Strict mode
|
||||
inserts after using/#Requires/param headers, keeps line numbers, and
|
||||
writes UTF-8 with BOM. A user-session run with no exit code is a
|
||||
failure. collect_log refuses answer files, agent config/state, key
|
||||
material and reparse points, and rechecks the handle's final path.
|
||||
Certificate renewal at start is bounded to 15s and exits non-zero so
|
||||
SCM recovery restarts the service. -->
|
||||
<Version>1.19.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
Reference in New Issue
Block a user