Pin the approved content digest on an endpoint command
Delivery descriptors for run_script and update_app are resolved when the
agent claims a command, not when an operator queues it, and the command
row recorded no digest: the enqueue path resolved the descriptor purely
to validate it and threw the result away.
So what an operator approved and what an endpoint runs were two separate
lookups against mutable content, minutes or hours apart. Anyone able to
replace a script body or an installer package could do so in the gap.
The claim then re-resolves, the descriptor faithfully describes the new
bytes, the agent verifies correctly, and it runs as LocalSystem. The
audit trail names the operator who queued the original.
The gate on the verb and the gate on the payload were also different
grants: pushing needs endpoints.deploy, but replacing what gets pushed
needs only applications.edit.
pinnedsha256 records the digest as it stood at enqueue, and the claim
refuses a descriptor that has moved, failing the push with an audit row
instead of shipping it. Null for actions that deliver no content, and
for rows queued before this migration, which claim as before. A
descriptor that vanished entirely is not treated as tampering: that path
already sends the command through undescribed so the agent fails closed.
Separately, a result may now only finalize a command that is actually
running. That needs no agent change and closes two things: reporting on
a command that was never claimed, and overwriting a recorded failure
with a success.
The agent now sends its hostname on the result callback so the server
can check it owns the command it is finalizing. The shared collector key
does not say who is calling, so without it any agent can post a result
for any command id. The server treats the parameter as optional for
agents that predate this and enforces it when present, so there is no
flag day. This is a floor, not a fix: only a per-endpoint credential
makes the caller's identity provable, and that is tracked separately.
The .NET side is unbuilt here and needs a build plus a win11 VM run.
Scope: product
(cherry picked from commit d64b7122ab)
This commit is contained in:
@@ -64,10 +64,17 @@ public sealed class CommandClient : IDisposable
|
||||
// result is logged and swallowed so a transient network blip does not crash
|
||||
// the worker; the command simply stays 'running' server-side until a later
|
||||
// operator retry supersedes it.
|
||||
// hostname rides along so the server can check this agent owns the command
|
||||
// it is finalizing. The shared collector key does not say who is calling,
|
||||
// so without it any agent can post a result for any command id. The server
|
||||
// treats the parameter as optional for older agents and enforces it when
|
||||
// present, so shipping this ahead of a fleet update is safe.
|
||||
public async Task ReportResultAsync(
|
||||
int commandId, CommandResult result, CancellationToken token)
|
||||
int commandId, string hostname, CommandResult result,
|
||||
CancellationToken token)
|
||||
{
|
||||
var url = $"api/endpoints/commands/{commandId}/result";
|
||||
var url = $"api/endpoints/commands/{commandId}/result?hostname="
|
||||
+ Uri.EscapeDataString(hostname);
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
url, result, JsonOptions, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
|
||||
@@ -130,7 +130,8 @@ public sealed class CommandWorker : BackgroundService
|
||||
};
|
||||
}
|
||||
|
||||
await client.ReportResultAsync(claim.EndpointCommandId, result, token);
|
||||
await client.ReportResultAsync(
|
||||
claim.EndpointCommandId, hostname, result, token);
|
||||
logger.LogInformation("Command {Id} finished succeeded={Succeeded}",
|
||||
claim.EndpointCommandId, result.Succeeded);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user