Make the one-line install survive Defender, and approval leave a PC that works
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

All three found testing the bootstrap installer on Windows.

- The published command used the irm-pipe-iex form, which Microsoft Defender
  detects as Trojan:Win32/Commando.A!ml and kills before it runs, every time,
  on a default install. The command now downloads the script to a file, runs
  it, and removes it in a finally block, which Defender does not object to.
  The UI also offers the script as a plain download for a tech who would
  rather save it.
- Approving a request only linked the asset, so a PC whose pending reason was
  "unknown hostname" - which is every PC installed this way - kept getting 403
  on every call afterwards, because its computer record had no hostname to
  match the credential against. Approval now stamps the hostname when the
  record has none, refuses with a 409 naming both when the record has a
  different one, and overwrites only when the admin confirms. The Credentials
  tab offers that confirmation.
- A hostname that resolves to no record now says so instead of reading as an
  impostor, while still refusing the request.
- While waiting for approval the agent retried on the 15-minute telemetry
  cadence, so a PC approved immediately enrolled a quarter of an hour later
  and the installer always ended on "still pending". It now retries about
  every minute for the first ten minutes, then every five to thirty minutes,
  then every fifteen, with jitter. A rate limit or server error still uses the
  slower backoff.
This commit is contained in:
cproudlock
2026-09-15 20:46:04 -04:00
parent ca57b7f728
commit 2557d1967d
12 changed files with 580 additions and 65 deletions

View File

@@ -13,9 +13,10 @@ namespace ShopDbEndpointAgent.Core;
// unenrolled or pending PC keeps reporting. // unenrolled or pending PC keeps reporting.
// //
// 201 enrolled store credential, swap provider, idle until woken // 201 enrolled store credential, swap provider, idle until woken
// 202 pending retry every 15 min +/- 2 min (admin approval, serial conflict) // 202 pending PendingSchedule: 1 min, then 5 min, then 15 min
// 404 server predates enrollment: idle, retry every 6 h // 404 server predates enrollment: idle, retry every 6 h
// other back off 1, 5, 15 min (cap) // other back off 1, 5, 15 min (cap); a 429 or 5xx lands here, so a
// rate limit drops out of the fast pending phase for that try
// //
// Woken early by the provider on CREDENTIAL_INVALID (credential deleted) and on // Woken early by the provider on CREDENTIAL_INVALID (credential deleted) and on
// SHARED_KEY_REFUSED while unenrolled, floored at one attempt a minute. // SHARED_KEY_REFUSED while unenrolled, floored at one attempt a minute.
@@ -23,8 +24,16 @@ public sealed class EnrollmentService : BackgroundService
{ {
public const string DeviceIdValue = "DeviceId"; public const string DeviceIdValue = "DeviceId";
private static readonly TimeSpan PendingRetry = TimeSpan.FromMinutes(15); // Retry while pending, by how long this request has been pending. An admin
private static readonly TimeSpan PendingJitter = TimeSpan.FromMinutes(2); // usually approves within a couple of minutes and a tech is standing there,
// so ask often at first. Jitter is added, never subtracted, so MinWakeInterval
// cannot clip it back into lockstep across a room of PCs.
private static readonly (TimeSpan Waited, TimeSpan Retry, TimeSpan Jitter)[] PendingSchedule =
{
(TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(8)),
(TimeSpan.FromMinutes(30), TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(20)),
(TimeSpan.MaxValue, TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(2)),
};
private static readonly TimeSpan UnsupportedRetry = TimeSpan.FromHours(6); private static readonly TimeSpan UnsupportedRetry = TimeSpan.FromHours(6);
private static readonly TimeSpan[] ErrorBackoff = private static readonly TimeSpan[] ErrorBackoff =
{ {
@@ -85,6 +94,10 @@ public sealed class EnrollmentService : BackgroundService
{ {
int failures = 0; int failures = 0;
var lastAttempt = DateTime.MinValue; var lastAttempt = DateTime.MinValue;
// Start of the current pending run; reset on a new request id so a
// reject-and-ask-again (or a revoke) gets the fast phase over again.
DateTime? pendingSince = null;
int? pendingRequest = null;
using var client = httpFactory.CreateClient(AgentHttpChannel.Enroll); using var client = httpFactory.CreateClient(AgentHttpChannel.Enroll);
try try
@@ -93,6 +106,8 @@ public sealed class EnrollmentService : BackgroundService
{ {
if (credentials.Current is not null) if (credentials.Current is not null)
{ {
pendingSince = null;
pendingRequest = null;
firstAttempt.TrySetResult(); firstAttempt.TrySetResult();
await wake.WaitAsync(stoppingToken); await wake.WaitAsync(stoppingToken);
continue; continue;
@@ -103,7 +118,7 @@ public sealed class EnrollmentService : BackgroundService
await Task.Delay(MinWakeInterval - sinceLast, stoppingToken); await Task.Delay(MinWakeInterval - sinceLast, stoppingToken);
lastAttempt = DateTime.UtcNow; lastAttempt = DateTime.UtcNow;
var outcome = await TryEnrollAsync(client, stoppingToken); var (outcome, requestId) = await TryEnrollAsync(client, stoppingToken);
firstAttempt.TrySetResult(); firstAttempt.TrySetResult();
TimeSpan delay; TimeSpan delay;
@@ -111,14 +126,24 @@ public sealed class EnrollmentService : BackgroundService
{ {
case EnrollOutcome.Enrolled: case EnrollOutcome.Enrolled:
failures = 0; failures = 0;
pendingSince = null;
pendingRequest = null;
continue; continue;
case EnrollOutcome.Pending: case EnrollOutcome.Pending:
failures = 0; failures = 0;
delay = PendingRetry + TimeSpan.FromSeconds( if (pendingSince is null
(jitter.NextDouble() * 2 - 1) * PendingJitter.TotalSeconds); || (requestId is not null && requestId != pendingRequest))
{
pendingSince = DateTime.UtcNow;
pendingRequest = requestId;
}
delay = PendingDelay(DateTime.UtcNow - pendingSince.Value);
logger.LogDebug("Next enrollment attempt in {Delay}.", delay);
break; break;
case EnrollOutcome.Unsupported: case EnrollOutcome.Unsupported:
failures = 0; failures = 0;
pendingSince = null;
pendingRequest = null;
delay = UnsupportedRetry; delay = UnsupportedRetry;
break; break;
default: default:
@@ -142,14 +167,26 @@ public sealed class EnrollmentService : BackgroundService
private enum EnrollOutcome { Enrolled, Pending, Unsupported, Failed } private enum EnrollOutcome { Enrolled, Pending, Unsupported, Failed }
private async Task<EnrollOutcome> TryEnrollAsync(HttpClient client, CancellationToken token) private TimeSpan PendingDelay(TimeSpan waited)
{
foreach (var step in PendingSchedule)
{
if (waited < step.Waited)
return step.Retry
+ TimeSpan.FromSeconds(jitter.NextDouble() * step.Jitter.TotalSeconds);
}
return PendingSchedule[^1].Retry;
}
private async Task<(EnrollOutcome Outcome, int? RequestId)> TryEnrollAsync(
HttpClient client, CancellationToken token)
{ {
var hostname = Environment.MachineName; var hostname = Environment.MachineName;
var serial = ReadBiosSerial(); var serial = ReadBiosSerial();
if (string.IsNullOrWhiteSpace(serial)) if (string.IsNullOrWhiteSpace(serial))
{ {
logger.LogError("Enrollment skipped: no BIOS serial number from WMI Win32_BIOS."); logger.LogError("Enrollment skipped: no BIOS serial number from WMI Win32_BIOS.");
return EnrollOutcome.Failed; return (EnrollOutcome.Failed, null);
} }
string nonce; string nonce;
@@ -160,7 +197,7 @@ public sealed class EnrollmentService : BackgroundService
catch (Exception error) catch (Exception error)
{ {
logger.LogError(error, "Enrollment skipped: could not read or create enroll.json."); logger.LogError(error, "Enrollment skipped: could not read or create enroll.json.");
return EnrollOutcome.Failed; return (EnrollOutcome.Failed, null);
} }
var request = new var request = new
@@ -183,7 +220,7 @@ public sealed class EnrollmentService : BackgroundService
logger.LogInformation( logger.LogInformation(
"Server has no enrollment endpoint (HTTP 404); staying on the shared key, retry in {Delay}.", "Server has no enrollment endpoint (HTTP 404); staying on the shared key, retry in {Delay}.",
UnsupportedRetry); UnsupportedRetry);
return EnrollOutcome.Unsupported; return (EnrollOutcome.Unsupported, null);
} }
if (response.StatusCode == HttpStatusCode.Created) if (response.StatusCode == HttpStatusCode.Created)
@@ -193,7 +230,7 @@ public sealed class EnrollmentService : BackgroundService
if (secret is null || !secret.StartsWith("sdbe_", StringComparison.Ordinal)) if (secret is null || !secret.StartsWith("sdbe_", StringComparison.Ordinal))
{ {
logger.LogError("Enrollment returned 201 without a usable credential."); logger.LogError("Enrollment returned 201 without a usable credential.");
return EnrollOutcome.Failed; return (EnrollOutcome.Failed, null);
} }
var credential = new AgentCredential var credential = new AgentCredential
@@ -214,18 +251,19 @@ public sealed class EnrollmentService : BackgroundService
"Enrolled as asset {AssetId} but could not store credential {Prefix}. " + "Enrolled as asset {AssetId} but could not store credential {Prefix}. " +
"Revoke it in ShopDB so this PC can enroll again.", "Revoke it in ShopDB so this PC can enroll again.",
credential.AssetId, credential.Prefix); credential.AssetId, credential.Prefix);
return EnrollOutcome.Failed; return (EnrollOutcome.Failed, null);
} }
return EnrollOutcome.Enrolled; return (EnrollOutcome.Enrolled, null);
} }
if (response.StatusCode == HttpStatusCode.Accepted) if (response.StatusCode == HttpStatusCode.Accepted)
{ {
var result = ParseResult(body); var result = ParseResult(body);
var requestId = result.GetInt("requestid");
logger.LogInformation( logger.LogInformation(
"Enrollment pending (request {RequestId}): {Reason}.", "Enrollment pending (request {RequestId}): {Reason}.",
result.GetInt("requestid"), result.GetString("reason") ?? "no reason given"); requestId, result.GetString("reason") ?? "no reason given");
return EnrollOutcome.Pending; return (EnrollOutcome.Pending, requestId);
} }
var refusal = AgentServerError.TryParse(body); var refusal = AgentServerError.TryParse(body);
@@ -234,19 +272,20 @@ public sealed class EnrollmentService : BackgroundService
logger.LogError( logger.LogError(
"Enrollment refused: the approved request belongs to a different enroll nonce. " + "Enrollment refused: the approved request belongs to a different enroll nonce. " +
"An admin must reject it so this PC can request again."); "An admin must reject it so this PC can request again.");
return EnrollOutcome.Pending; // No request id: keeps the current pending run and its schedule.
return (EnrollOutcome.Pending, null);
} }
logger.LogWarning("Enrollment failed: HTTP {Status} {Code} {Message}", logger.LogWarning("Enrollment failed: HTTP {Status} {Code} {Message}",
(int)response.StatusCode, refusal?.Code, refusal?.Message); (int)response.StatusCode, refusal?.Code, refusal?.Message);
return EnrollOutcome.Failed; return (EnrollOutcome.Failed, null);
} }
catch (Exception error) when ( catch (Exception error) when (
error is HttpRequestException or TaskCanceledException or JsonException error is HttpRequestException or TaskCanceledException or JsonException
&& !token.IsCancellationRequested) && !token.IsCancellationRequested)
{ {
logger.LogWarning("Enrollment request failed: {Message}", error.Message); logger.LogWarning("Enrollment request failed: {Message}", error.Message);
return EnrollOutcome.Failed; return (EnrollOutcome.Failed, null);
} }
} }

View File

@@ -621,9 +621,9 @@ request row per hostname and serial (case-insensitive); repeated calls update it
| Status | Body | Agent action | | Status | Body | Agent action |
|---|---|---| |---|---|---|
| 201 | `{"status":"enrolled","credential":"sdbe_...","assetid":123}` | store, switch headers, no restart | | 201 | `{"status":"enrolled","credential":"sdbe_...","assetid":123}` | store, switch headers, no restart |
| 202 | `{"status":"pending","requestid":45,"reason":"serial mismatch"}` | retry every 15 min with jitter | | 202 | `{"status":"pending","requestid":45,"reason":"serial mismatch"}` | retry about every 60 s for the first 10 min, then every 5 min to 30 min, then every 15 min indefinitely, with jitter |
| 403 `ENROLL_NONCE_MISMATCH` | error envelope | an approved request belongs to a different nonce; keep retrying, an admin must reject and re-approve | | 403 `ENROLL_NONCE_MISMATCH` | error envelope | an approved request belongs to a different nonce; keep retrying, an admin must reject and re-approve |
| other | error envelope | back off 1, 5, 15 min (cap) | | other | error envelope | back off 1, 5, 15 min (cap); a 429 or 5xx uses this backoff, not the pending schedule |
Auto-issue happens only when `plugin.endpoints.autoenroll` is true, the shared Auto-issue happens only when `plugin.endpoints.autoenroll` is true, the shared
key authenticated the call, the hostname matches exactly one computer, the key authenticated the call, the hostname matches exactly one computer, the
@@ -645,6 +645,14 @@ All use the standard error envelope (`data.error.code`).
| 401 | `SHARED_KEY_REFUSED` | shared key sent, but this PC is enrolled or the shared key is switched off | wake enrollment; use the credential | | 401 | `SHARED_KEY_REFUSED` | shared key sent, but this PC is enrolled or the shared key is switched off | wake enrollment; use the credential |
| 403 | `ASSET_MISMATCH` | the credential's asset differs from the hostname/assetid named in the request | log; do not retry the same request | | 403 | `ASSET_MISMATCH` | the credential's asset differs from the hostname/assetid named in the request | log; do not retry the same request |
`ASSET_MISMATCH` also covers the case where the hostname the agent sends is on
NO computer record at all, which is a data problem on the server (an enrolled PC
whose computer record has no hostname) rather than a PC claiming to be another
one. The code and status are the same, so nothing changes for the agent, but the
message says which of the two it is and names the hostname. An admin fixes it by
setting the hostname on the enrolled computer; approving an enroll request does
that on its own (9.7).
Routes that authenticate this way: `POST /api/endpoints/collect` (same handler Routes that authenticate this way: `POST /api/endpoints/collect` (same handler
as `/api/collector/endpoints`), `GET /api/endpoints/monitors/resolved`, as `/api/collector/endpoints`), `GET /api/endpoints/monitors/resolved`,
`GET /api/endpoints/commands/next`, `POST /api/endpoints/commands/<id>/result`, `GET /api/endpoints/commands/next`, `POST /api/endpoints/commands/<id>/result`,
@@ -729,7 +737,16 @@ Read needs `endpoints.fleet`; writes need the admin role and are audited.
in state `pending`; `counts.pending` is the number of pending enroll requests, in state `pending`; `counts.pending` is the number of pending enroll requests,
including requests that match no computer (unknown hostname) and requests from including requests that match no computer (unknown hostname) and requests from
PCs blocked from re-enrolling, so it equals the pending entries in `requests`. PCs blocked from re-enrolling, so it equals the pending entries in `requests`.
- `POST /api/endpoints/enrollment/requests/<id>/approve` `{"assetid": n}` - `POST /api/endpoints/enrollment/requests/<id>/approve`
`{"assetid": n, "sethostname": false}`. The approval also makes sure the
computer CARRIES the request's hostname, because the credential is bound by
hostname afterwards (9.3): a computer with none is stamped with it
(`hostnameresult` `set`), a computer already carrying the same one is left
alone (`unchanged`), and a computer carrying a DIFFERENT hostname is refused
with **409** naming both plus `details.hostnameconflict`, until the same call
is repeated with `"sethostname": true` (`overwritten`). Both changes are
audited against `Computer`. Approving without this left a one-off PC 403ing
on every call after it enrolled.
- `POST /api/endpoints/enrollment/requests/<id>/reject` - `POST /api/endpoints/enrollment/requests/<id>/reject`
- `POST /api/endpoints/enrollment/assets/<assetid>/revoke` (un-enroll). Optional - `POST /api/endpoints/enrollment/assets/<assetid>/revoke` (un-enroll). Optional
body `{"blockreenroll": true, "reason": "rollback"}` (`reason` <= 64 chars, body `{"blockreenroll": true, "reason": "rollback"}` (`reason` <= 64 chars,
@@ -774,9 +791,28 @@ One-line install and hands a technician one command, which is run in an
ELEVATED PowerShell on a PC with no agent: ELEVATED PowerShell on a PC with no agent:
``` ```
powershell -ExecutionPolicy Bypass -c "irm <baseurl>/api/endpoints/siteagent/bootstrap.ps1?t=<token> | iex" powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1?t=<token>' -OutFile 'C:\Windows\Temp\shopdb-bootstrap.ps1'; & 'C:\Windows\Temp\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Windows\Temp\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
``` ```
Download then run, never `irm ... | iex`: Microsoft Defender scores the
piped-to-iex shape as `Trojan:Win32/Commando.A!ml` and kills the process before
a line of it runs (reproduced 5 of 5 on a default install, no ASR rules), and
every bay runs Defender. The shape of the line is load-bearing too:
- a literal temp path and no `$` anywhere, because pasting into an
already-running PowerShell would otherwise expand `$p` and `$env:TEMP` in the
PARENT and hand the child a broken line - as written it means the same in
cmd.exe and in PowerShell;
- single quotes for every inner literal, so the outer `-c "..."` is never
closed early;
- `try`/`finally`, so the dropped file is removed even when the script exits
non-zero or the download itself fails. The wrapper's own exit code is the
removal's, so read the script's printed result rather than `%ERRORLEVEL%`.
The same URL is offered in the UI as a **Download .ps1** link, for a tech who
would rather save the file and run it. Fetching it does not spend a use either
way.
The token is only an authorization to FETCH the installer. The package it The token is only an authorization to FETCH the installer. The package it
serves carries no shared key, whatever `plugin.endpoints.sharedkeyallowed` serves carries no shared key, whatever `plugin.endpoints.sharedkeyallowed`
says, so the PC enrolls by the normal 9.2 path and waits in Pending until an says, so the PC enrolls by the normal 9.2 path and waits in Pending until an
@@ -785,7 +821,9 @@ admin approves it.
Admin routes (admin role, audited): Admin routes (admin role, audited):
- `POST /api/endpoints/siteagent/bootstrap/tokens` `{"hours": 24, "maxuses": 25}` - `POST /api/endpoints/siteagent/bootstrap/tokens` `{"hours": 24, "maxuses": 25}`
(`hours` 1-168, `maxuses` 1-500). Returns `{"secret", "command", "token"}`. (`hours` 1-168, `maxuses` 1-500). Returns
`{"secret", "command", "scripturl", "token"}`; `scripturl` is the same token
URL the command downloads, for the UI's Download .ps1 link.
The secret is in the response exactly once; only its sha256 and a 12-character The secret is in the response exactly once; only its sha256 and a 12-character
`tokenprefix` are stored. `tokenprefix` are stored.
- `GET /api/endpoints/siteagent/bootstrap/tokens` - prefix, created, expires, - `GET /api/endpoints/siteagent/bootstrap/tokens` - prefix, created, expires,

View File

@@ -155,6 +155,23 @@ def asset_mismatch_response():
http_code=403) http_code=403)
def hostname_unrecorded_response(hostname):
"""Refused, but say which of the two failures this actually is.
The credential is good and the hostname is simply on no computer record, so
there is nothing to compare it with. That is a data problem on this server -
an enrolled PC whose computer record carries no hostname - not a PC claiming
to be another one. Same code and status, because nothing changes for the
agent: it keeps retrying, and an admin has to set the hostname.
"""
return error_response(
CODE_ASSET_MISMATCH,
'No computer record carries the hostname {0}, so this credential '
'cannot be matched to it. Set the hostname on the enrolled '
'computer.'.format(hostname),
http_code=403)
def authenticate_agent(): def authenticate_agent():
"""Authorize an agent request. Returns None when authorized, else a response. """Authorize an agent request. Returns None when authorized, else a response.
@@ -190,14 +207,19 @@ def bind_hostname(hostname):
"""The asset this request acts for. Returns (assetid, error_response). """The asset this request acts for. Returns (assetid, error_response).
Credential: the credential's asset; a hostname naming another asset is an Credential: the credential's asset; a hostname naming another asset is an
ASSET_MISMATCH, and a missing hostname is fine. ASSET_MISMATCH, a hostname on no computer record at all is refused with the
same code but its own message, and a missing hostname is fine.
Shared key: the hostname's asset (None when absent or unknown, for the Shared key: the hostname's asset (None when absent or unknown, for the
route to answer); an enrolled PC is refused. route to answer); an enrolled PC is refused.
""" """
hostname = (hostname or '').strip() hostname = (hostname or '').strip()
if is_credential_caller(): if is_credential_caller():
if hostname and resolve_hostname(hostname) != g.agentassetid: if hostname:
return None, asset_mismatch_response() named = resolve_hostname(hostname)
if named is None:
return None, hostname_unrecorded_response(hostname)
if named != g.agentassetid:
return None, asset_mismatch_response()
return g.agentassetid, None return g.agentassetid, None
if not hostname: if not hostname:

View File

@@ -1,9 +1,8 @@
"""One-line bootstrap installer for the endpoint agent. """One-line bootstrap installer for the endpoint agent.
An admin mints a short-lived token in Settings > Endpoints and hands a tech one An admin mints a short-lived token in Settings > Endpoints and hands a tech one
command. On a PC with no agent, in an ELEVATED PowerShell: command. On a PC with no agent, in an ELEVATED PowerShell: the command
downloads bootstrap.ps1 to a file, runs it, and deletes it again.
powershell -ExecutionPolicy Bypass -c "irm <base>/api/endpoints/siteagent/bootstrap.ps1?t=<token> | iex"
That installs the agent and tray with NO shared key, so the PC asks to enroll That installs the agent and tray with NO shared key, so the PC asks to enroll
and waits for an admin to approve it in Settings > Endpoints > Enrollment. This and waits for an admin to approve it in Settings > Endpoints > Enrollment. This
@@ -61,6 +60,13 @@ LIST_LIMIT = 100
SCRIPT_PATH = '/api/endpoints/siteagent/bootstrap.ps1' SCRIPT_PATH = '/api/endpoints/siteagent/bootstrap.ps1'
PACKAGE_PATH = '/api/endpoints/siteagent/bootstrap/package' PACKAGE_PATH = '/api/endpoints/siteagent/bootstrap/package'
# Where the one-line command drops the script before running it. A literal, not
# $env:TEMP: the line has to survive being pasted into an already-running
# PowerShell, which expands every $ inside the outer double quotes before the
# child process sees them. The install needs elevation anyway, so this path is
# writable by definition.
COMMAND_SCRIPT_PATH = r'C:\Windows\Temp\shopdb-bootstrap.ps1'
# Fixed-window per-IP limiter, same shape and cache extension as the login # Fixed-window per-IP limiter, same shape and cache extension as the login
# limiter in shopdb.core.api.auth and the GE-Enforce payload limiter. A tech # limiter in shopdb.core.api.auth and the GE-Enforce payload limiter. A tech
# fetching a script and a package is two hits; a scanner walking token guesses # fetching a script and a package is two hits; a scanner walking token guesses
@@ -145,11 +151,31 @@ def bootstrap_base_url():
return 'https://{0}{1}'.format(host, root) return 'https://{0}{1}'.format(host, root)
def bootstrap_command(secret, baseurl=None): def bootstrap_script_url(secret, baseurl=None):
"""The one line a tech pastes into an elevated PowerShell.""" """The token URL that serves the script: downloaded by the command, and
offered in the UI as a plain file a tech can save and run instead."""
base = (baseurl if baseurl is not None else bootstrap_base_url()).rstrip('/') base = (baseurl if baseurl is not None else bootstrap_base_url()).rstrip('/')
return ('powershell -ExecutionPolicy Bypass -c "irm {0}{1}?t={2} | iex"' return '{0}{1}?t={2}'.format(base, SCRIPT_PATH, secret)
.format(base, SCRIPT_PATH, secret))
def bootstrap_command(secret, baseurl=None):
"""The one line a tech pastes into an elevated PowerShell.
Download to a file and run it, never `irm ... | iex`: Microsoft Defender
scores the piped-to-iex shape as Trojan:Win32/Commando.A!ml and kills the
process before a line of it runs. Every bay runs Defender.
Single quotes inside and no $ anywhere, so the one line means the same
whether it is pasted into cmd.exe or into a PowerShell prompt. The download
and the run are wrapped in try/finally so the dropped file goes away even
when the script exits non-zero or the download itself fails.
"""
return ('powershell -ExecutionPolicy Bypass -c "'
"try {{ Invoke-WebRequest -UseBasicParsing '{0}' -OutFile '{1}'; "
"& '{1}' }} finally {{ Remove-Item '{1}' -Force "
'-ErrorAction SilentlyContinue }}"'
).format(bootstrap_script_url(secret, baseurl),
COMMAND_SCRIPT_PATH)
def _hash_token(secret): def _hash_token(secret):
@@ -448,6 +474,7 @@ def create_bootstrap_token():
'token': row.to_dict(now), 'token': row.to_dict(now),
'secret': secret, 'secret': secret,
'command': bootstrap_command(secret), 'command': bootstrap_command(secret),
'scripturl': bootstrap_script_url(secret),
}, },
message='Bootstrap token created. The command is shown once.', message='Bootstrap token created. The command is shown once.',
http_code=201) http_code=201)
@@ -487,7 +514,10 @@ def revoke_bootstrap_token(tokenid):
@endpoints_bp.route('/siteagent/bootstrap.ps1', methods=['GET']) @endpoints_bp.route('/siteagent/bootstrap.ps1', methods=['GET'])
def serve_bootstrap_script(): def serve_bootstrap_script():
"""The PowerShell the one-line command pipes into iex. """The PowerShell the one-line command downloads, runs and deletes.
The same URL is the UI's "Download .ps1" link, for a tech who would rather
save the file and run it than paste a command.
Fetching the script does NOT spend a use: a tech who pastes the command Fetching the script does NOT spend a use: a tech who pastes the command
twice, or a shell that retries, must not burn the budget the package twice, or a shell that retries, must not burn the budget the package

View File

@@ -65,6 +65,19 @@ REASON_NONCE_CHANGED = 'nonce changed'
REASON_REJECTED = 'rejected' REASON_REJECTED = 'rejected'
REASON_REENROLL_BLOCKED = 'reenroll blocked' REASON_REENROLL_BLOCKED = 'reenroll blocked'
# What an approval did to the computer's hostname. Reported back so the UI can
# say so, and so an admin can tell a rename from a no-op.
HOSTNAME_UNCHANGED = 'unchanged'
HOSTNAME_SET = 'set'
HOSTNAME_OVERWRITTEN = 'overwritten'
HOSTNAME_MESSAGES = {
HOSTNAME_UNCHANGED: 'Enroll request approved',
HOSTNAME_SET: 'Enroll request approved; the hostname is now recorded on '
'that computer',
HOSTNAME_OVERWRITTEN: 'Enroll request approved; that computer was renamed '
'to the hostname the PC reports',
}
MIGRATION_STATES = ('enrolled', 'pending', 'upgrading', 'ready', 'blocked', MIGRATION_STATES = ('enrolled', 'pending', 'upgrading', 'ready', 'blocked',
'notreporting') 'notreporting')
ENROLLMENT_STATES = ('enrolled', 'notenrolled', 'pending', 'revoked') ENROLLMENT_STATES = ('enrolled', 'notenrolled', 'pending', 'revoked')
@@ -557,7 +570,21 @@ def get_enrollment():
methods=['POST']) methods=['POST'])
@require_role('admin') @require_role('admin')
def approve_enroll_request(requestid): def approve_enroll_request(requestid):
"""Bind a pending request to the asset the admin picked.""" """Bind a pending request to the asset the admin picked.
The computer record also has to CARRY the request's hostname, or the PC
403s on every call after it collects its credential: bind_hostname resolves
the hostname the agent sends to an asset and compares it with the
credential's asset, and a computer with no hostname resolves to nothing.
The pending reason for a one-off PC is by definition `unknown hostname`, so
this is the normal case, not the exception.
Body: {"assetid": 123, "sethostname": false}. A computer with no hostname
is stamped with the request's. A computer carrying a DIFFERENT hostname is
refused with 409 naming both, until the caller repeats the approval with
"sethostname": true. Either change is audited, and the response says which
of unchanged / set / overwritten happened.
"""
row = db.session.get(EndpointEnrollRequest, requestid) row = db.session.get(EndpointEnrollRequest, requestid)
if row is None: if row is None:
return error_response(ErrorCodes.NOT_FOUND, return error_response(ErrorCodes.NOT_FOUND,
@@ -572,8 +599,13 @@ def approve_enroll_request(requestid):
if isinstance(assetid, bool) or not isinstance(assetid, int): if isinstance(assetid, bool) or not isinstance(assetid, int):
return error_response(ErrorCodes.VALIDATION_ERROR, return error_response(ErrorCodes.VALIDATION_ERROR,
'assetid must be an integer') 'assetid must be an integer')
sethostname = payload.get('sethostname', False)
if not isinstance(sethostname, bool):
return error_response(ErrorCodes.VALIDATION_ERROR,
'sethostname must be true or false')
Computer = _computer_model() Computer = _computer_model()
if Computer.query.filter_by(assetid=assetid).first() is None: computer = Computer.query.filter_by(assetid=assetid).first()
if computer is None:
return error_response(ErrorCodes.VALIDATION_ERROR, return error_response(ErrorCodes.VALIDATION_ERROR,
f'Asset {assetid} is not a computer') f'Asset {assetid} is not a computer')
if agentauth.active_credential_for(assetid) is not None: if agentauth.active_credential_for(assetid) is not None:
@@ -585,6 +617,35 @@ def approve_enroll_request(requestid):
'That computer is blocked from enrolling again; ' 'That computer is blocked from enrolling again; '
'allow re-enrollment first', http_code=409) 'allow re-enrollment first', http_code=409)
requesthostname = (row.hostname or '').strip()
currenthostname = (computer.hostname or '').strip()
hostnameresult = HOSTNAME_UNCHANGED
if requesthostname and not currenthostname:
hostnameresult = HOSTNAME_SET
elif requesthostname and currenthostname.lower() != requesthostname.lower():
if not sethostname:
return error_response(
ErrorCodes.CONFLICT,
'That computer is recorded as {0}, but the request comes from '
'{1}. Approving as it stands leaves the PC unable to '
'authenticate. Approve again with "sethostname": true to '
'rename the computer to {1}.'.format(currenthostname,
requesthostname),
details={'hostnameconflict': True,
'computerhostname': currenthostname,
'requesthostname': requesthostname},
http_code=409)
hostnameresult = HOSTNAME_OVERWRITTEN
if hostnameresult != HOSTNAME_UNCHANGED:
computer.hostname = requesthostname
AuditLog.log('updated', 'Computer', entityid=computer.computerid,
entityname=requesthostname,
changes={'hostname': {'old': currenthostname or None,
'new': requesthostname}},
details='hostname {0} from enroll request {1}'.format(
hostnameresult, row.requestid))
row.status = EndpointEnrollRequest.STATUS_APPROVED row.status = EndpointEnrollRequest.STATUS_APPROVED
row.matchedassetid = assetid row.matchedassetid = assetid
row.reason = None row.reason = None
@@ -592,9 +653,12 @@ def approve_enroll_request(requestid):
row.decidedby = _username() row.decidedby = _username()
AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid, AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid,
entityname=row.hostname, entityname=row.hostname,
details='enroll request approved for asset {}'.format(assetid)) details='enroll request approved for asset {0}, hostname {1}'
.format(assetid, hostnameresult))
db.session.commit() db.session.commit()
return success_response(row.to_dict(), message='Enroll request approved') approved = row.to_dict()
approved['hostnameresult'] = hostnameresult
return success_response(approved, message=HOSTNAME_MESSAGES[hostnameresult])
@endpoints_bp.route('/enrollment/requests/<int:requestid>/reject', @endpoints_bp.route('/enrollment/requests/<int:requestid>/reject',

View File

@@ -262,7 +262,12 @@ Repeat until **Ready** is 0.
mismatch, duplicate or unknown hostname, already enrolled, or auto-enroll mismatch, duplicate or unknown hostname, already enrolled, or auto-enroll
off). Credentials tab -> Requests -> **Approve**; the picker defaults to the off). Credentials tab -> Requests -> **Approve**; the picker defaults to the
matched computer, search to choose another. The PC collects its credential on matched computer, search to choose another. The PC collects its credential on
its next retry (within 15 minutes). **Reject** anything you do not recognise. its next retry - about a minute for a PC that started asking recently,
slowing to 15 minutes once it has been pending a while. **Reject** anything
you do not recognise. Approving also records the hostname the PC reports on
the computer you picked when that record has none; if the record carries a
DIFFERENT hostname the approval stops and asks, because the PC cannot
authenticate against a computer recorded under another name.
- **Newly imaged PCs:** imaging installs 1.20 with the shared key, and the agent - **Newly imaged PCs:** imaging installs 1.20 with the shared key, and the agent
auto-enrolls on first start. A new PC can also be installed WITHOUT the auto-enrolls on first start. A new PC can also be installed WITHOUT the
shared key at any time, before or after cutover (the generated installer's shared key at any time, before or after cutover (the generated installer's
@@ -288,15 +293,30 @@ says so when it finds an agent already installed.
3. On the target PC, open PowerShell **as administrator** and paste it: 3. On the target PC, open PowerShell **as administrator** and paste it:
``` ```
powershell -ExecutionPolicy Bypass -c "irm https://<server>/api/endpoints/siteagent/bootstrap.ps1?t=<token> | iex" powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest -UseBasicParsing 'https://<server>/api/endpoints/siteagent/bootstrap.ps1?t=<token>' -OutFile 'C:\Windows\Temp\shopdb-bootstrap.ps1'; & 'C:\Windows\Temp\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Windows\Temp\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
``` ```
It downloads the script, runs it and deletes it again. Do not shorten it back
to `irm ... | iex`: Defender scores that shape as `Trojan:Win32/Commando.A!ml`
and kills the process before anything runs, on a default install with no ASR
rules. Paste it as one line into an elevated **cmd** or **PowerShell** window;
it means the same in both. **Download .ps1** in the same block saves the script
instead, for a PC where pasting a long line is awkward - it carries the same
token, so treat the file the way you treat the command.
The token is short-lived and only fetches the installer. It carries no shared The token is short-lived and only fetches the installer. It carries no shared
key and provisions nothing by itself: the package it downloads is built with key and provisions nothing by itself: the package it downloads is built with
"Include shared key" off, whatever the shared-key setting is, so the PC asks to "Include shared key" off, whatever the shared-key setting is, so the PC asks to
enroll and **waits for approval**. Approve it in Enrollment -> Credentials -> enroll and **waits for approval**. Approve it in Enrollment -> Credentials ->
Requests, the same as any other pending PC (phase 5). Requests, the same as any other pending PC (phase 5).
A one-off PC is pending for `unknown hostname` by definition, so pick (or
create) the computer record it belongs to. The approval records the hostname
the PC reports on that record, which is what the PC authenticates against
afterwards; picking a record already named something else stops the approval
and asks before renaming it. Skipping that step is what used to leave a
bootstrapped PC enrolled and then refused (403) on every call.
The script checks the downloaded binaries against the SHA-256 values the server The script checks the downloaded binaries against the SHA-256 values the server
stamped into it, refuses to run unelevated, and refuses to run on a PC that stamped into it, refuses to run unelevated, and refuses to run on a PC that
already has the agent service (set `SHOPDB_BOOTSTRAP_FORCE=1` in the same already has the agent service (set `SHOPDB_BOOTSTRAP_FORCE=1` in the same

View File

@@ -458,6 +458,18 @@
{{ bootstrapcopied ? 'Copied' : 'Copy' }} {{ bootstrapcopied ? 'Copied' : 'Copy' }}
</button> </button>
</div> </div>
<p v-if="bootstrapscripturl" class="field-hint">
Or save the script and run it on the PC instead:
<a
class="bootstrap-download"
:href="bootstrapscripturl"
download="shopdb-bootstrap.ps1"
>
Download .ps1
</a>
(the same token; it carries the secret, so treat the file the
way you treat the command).
</p>
<p v-if="bootstraptoken" class="field-hint"> <p v-if="bootstraptoken" class="field-hint">
Expires {{ formatDate(bootstraptoken.expiresat) }}. Expires {{ formatDate(bootstraptoken.expiresat) }}.
{{ bootstraptoken.remaining }} of {{ bootstraptoken.remaining }} of
@@ -745,6 +757,7 @@ const bootstraphours = ref(24)
const bootstrapmaxuses = ref(25) const bootstrapmaxuses = ref(25)
const bootstrapbusy = ref(false) const bootstrapbusy = ref(false)
const bootstrapcommand = ref('') const bootstrapcommand = ref('')
const bootstrapscripturl = ref('')
const bootstraptoken = ref(null) const bootstraptoken = ref(null)
const bootstrapcopied = ref(false) const bootstrapcopied = ref(false)
const bootstraptokens = ref([]) const bootstraptokens = ref([])
@@ -880,6 +893,7 @@ async function generateBootstrapToken() {
bootstrapmessage.value = '' bootstrapmessage.value = ''
bootstraperror.value = '' bootstraperror.value = ''
bootstrapcommand.value = '' bootstrapcommand.value = ''
bootstrapscripturl.value = ''
bootstraptoken.value = null bootstraptoken.value = null
bootstrapcopied.value = false bootstrapcopied.value = false
try { try {
@@ -889,6 +903,7 @@ async function generateBootstrapToken() {
}) })
const data = response.data.data || {} const data = response.data.data || {}
bootstrapcommand.value = data.command || '' bootstrapcommand.value = data.command || ''
bootstrapscripturl.value = data.scripturl || ''
bootstraptoken.value = data.token || null bootstraptoken.value = data.token || null
bootstrapmessage.value = 'Install command generated.' bootstrapmessage.value = 'Install command generated.'
await loadBootstrapTokens() await loadBootstrapTokens()

View File

@@ -330,9 +330,13 @@
</button> </button>
</div> </div>
<p class="field-hint"> <p class="field-hint">
The PC receives its credential the next time it asks, The PC receives its credential the next time it asks:
which is within 15 minutes. Only the PC that made this about a minute for one that started asking recently,
request can collect it. slowing to 15 minutes once it has been pending a while.
Only the PC that made this request can collect it.
If the computer record has no hostname, approving
records the one the PC reports, which it needs to
authenticate afterwards.
</p> </p>
</td> </td>
</tr> </tr>
@@ -775,6 +779,28 @@ async function searchAssets() {
} }
} }
// The approval also stamps the request's hostname onto the computer when that
// record has none - without it the PC collects its credential and then 403s on
// every call, because the server resolves the hostname it sends to nothing. A
// computer already carrying a DIFFERENT hostname comes back as a 409 naming
// both, and only an explicit second answer renames it.
async function postApprove(current, sethostname) {
const body = { assetid: current.assetid }
if (sethostname) body.sethostname = true
const response = await api.post(
`/endpoints/enrollment/requests/${current.requestid}/approve`, body)
const result = response?.data?.data?.hostnameresult
if (result === 'set') {
toast.success(`Approved ${current.hostname}; hostname recorded on that PC`)
} else if (result === 'overwritten') {
toast.success(`Approved ${current.hostname}; that PC was renamed to it`)
} else {
toast.success(`Approved ${current.hostname}`)
}
approving.value = null
await loadEnrollment({ quiet: true })
}
async function approve() { async function approve() {
const current = approving.value const current = approving.value
if (!current || !current.assetid) return if (!current || !current.assetid) return
@@ -783,13 +809,24 @@ async function approve() {
+ `${option ? option.label : 'asset ' + current.assetid}?`)) return + `${option ? option.label : 'asset ' + current.assetid}?`)) return
busy.value = true busy.value = true
try { try {
await api.post(`/endpoints/enrollment/requests/${current.requestid}/approve`, await postApprove(current, false)
{ assetid: current.assetid })
toast.success(`Approved ${current.hostname}`)
approving.value = null
await loadEnrollment({ quiet: true })
} catch (error) { } catch (error) {
toast.error(apiError(error, 'Could not approve the request')) const details = error?.response?.data?.data?.error?.details
const message = apiError(error, 'Could not approve the request')
if (details?.hostnameconflict) {
if (confirm(`${message}\n\nSet the hostname on this PC?`)) {
try {
await postApprove(current, true)
return
} catch (retryError) {
toast.error(apiError(retryError, 'Could not approve the request'))
return
}
}
toast.error(message)
return
}
toast.error(message)
} finally { } finally {
busy.value = false busy.value = false
} }

View File

@@ -19,9 +19,14 @@ import AgentKeySettings from './AgentKeySettings.vue'
const TOKENS_URL = '/endpoints/siteagent/bootstrap/tokens' const TOKENS_URL = '/endpoints/siteagent/bootstrap/tokens'
const SECRET = 'sdbb_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG' const SECRET = 'sdbb_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG'
const SCRIPT_URL =
`https://shopdb.test/shopdb/api/endpoints/siteagent/bootstrap.ps1?t=${SECRET}`
// Download then run: Defender kills the `irm ... | iex` shape before it runs.
const TEMP = 'C:\\Windows\\Temp\\shopdb-bootstrap.ps1'
const COMMAND = const COMMAND =
'powershell -ExecutionPolicy Bypass -c "irm https://shopdb.test/shopdb' 'powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest '
+ `/api/endpoints/siteagent/bootstrap.ps1?t=${SECRET} | iex"` + `-UseBasicParsing '${SCRIPT_URL}' -OutFile '${TEMP}'; & '${TEMP}' }`
+ ` finally { Remove-Item '${TEMP}' -Force -ErrorAction SilentlyContinue }"`
const MINTED_TOKEN = { const MINTED_TOKEN = {
tokenid: 7, tokenid: 7,
@@ -81,7 +86,7 @@ describe('AgentKeySettings one-line install', () => {
it('generate posts the hours and maximum uses', async () => { it('generate posts the hours and maximum uses', async () => {
api.post.mockResolvedValue({ api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } } data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
}) })
const wrapper = await render() const wrapper = await render()
@@ -95,7 +100,7 @@ describe('AgentKeySettings one-line install', () => {
it('defaults to 24 hours and 25 uses', async () => { it('defaults to 24 hours and 25 uses', async () => {
api.post.mockResolvedValue({ api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } } data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
}) })
const wrapper = await render() const wrapper = await render()
@@ -109,7 +114,7 @@ describe('AgentKeySettings one-line install', () => {
it('renders the returned command, the once-only warning and the expiry', async () => { it('renders the returned command, the once-only warning and the expiry', async () => {
api.post.mockResolvedValue({ api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } } data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
}) })
const wrapper = await render() const wrapper = await render()
await generateButton(wrapper).trigger('click') await generateButton(wrapper).trigger('click')
@@ -126,11 +131,27 @@ describe('AgentKeySettings one-line install', () => {
expect(block).toContain('uses remaining') expect(block).toContain('uses remaining')
}) })
it('offers the same token as a Download .ps1 link', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
expect(wrapper.find('.bootstrap-download').exists()).toBe(false)
await generateButton(wrapper).trigger('click')
await flushPromises()
const link = wrapper.find('.bootstrap-download')
expect(link.attributes('href')).toBe(SCRIPT_URL)
expect(link.attributes('download')).toBe('shopdb-bootstrap.ps1')
expect(wrapper.find('#bootstrap-command').element.value).not.toContain(' | iex')
})
it('copies the command to the clipboard', async () => { it('copies the command to the clipboard', async () => {
const writeText = vi.fn(() => Promise.resolve()) const writeText = vi.fn(() => Promise.resolve())
vi.stubGlobal('navigator', { clipboard: { writeText } }) vi.stubGlobal('navigator', { clipboard: { writeText } })
api.post.mockResolvedValue({ api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } } data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
}) })
const wrapper = await render() const wrapper = await render()
await generateButton(wrapper).trigger('click') await generateButton(wrapper).trigger('click')
@@ -171,7 +192,7 @@ describe('AgentKeySettings one-line install', () => {
it('does not show the secret again after a reload', async () => { it('does not show the secret again after a reload', async () => {
api.post.mockResolvedValue({ api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } } data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
}) })
const first = await render() const first = await render()
await generateButton(first).trigger('click') await generateButton(first).trigger('click')

View File

@@ -189,6 +189,69 @@ describe('EnrollmentSettings', () => {
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve', { assetid: 77 }) expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve', { assetid: 77 })
}) })
// Approving a one-off PC onto a computer recorded under another hostname is
// refused by the server, because the PC authenticates by hostname afterwards.
// The page has to show that message and let the admin answer it, not swallow
// it as "could not approve".
function hostnameConflict() {
return {
response: {
status: 409,
data: {
data: {
error: {
code: 'CONFLICT',
message: 'That computer is recorded as PC-OLD, but the request comes from PC-NEW.',
details: {
hostnameconflict: true,
computerhostname: 'PC-OLD',
requesthostname: 'PC-NEW'
}
}
}
}
}
}
}
it('offers to set the hostname when approve comes back 409, then re-posts', async () => {
const confirmSpy = vi.fn(() => true)
vi.stubGlobal('confirm', confirmSpy)
const wrapper = await render()
await openCredentials(wrapper)
api.post.mockReset()
api.post.mockRejectedValueOnce(hostnameConflict())
api.post.mockResolvedValue({ data: { data: { hostnameresult: 'overwritten' } } })
await wrapper.find('.approve-open').trigger('click')
await wrapper.find('.approve-confirm').trigger('click')
await flushPromises()
expect(api.post.mock.calls).toEqual([
['/endpoints/enrollment/requests/45/approve', { assetid: 77 }],
['/endpoints/enrollment/requests/45/approve', { assetid: 77, sethostname: true }]
])
const asked = confirmSpy.mock.calls.at(-1)[0]
expect(asked).toContain('recorded as PC-OLD')
expect(asked).toContain('Set the hostname on this PC?')
})
it('sends nothing more when the hostname question is declined', async () => {
vi.stubGlobal('confirm', vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false))
const wrapper = await render()
await openCredentials(wrapper)
api.post.mockReset()
api.post.mockRejectedValue(hostnameConflict())
await wrapper.find('.approve-open').trigger('click')
await wrapper.find('.approve-confirm').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledTimes(1)
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve',
{ assetid: 77 })
})
it('rejects a request after confirm', async () => { it('rejects a request after confirm', async () => {
vi.stubGlobal('confirm', vi.fn(() => true)) vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render() const wrapper = await render()

View File

@@ -27,6 +27,17 @@ TOKENS_URL = '/api/endpoints/siteagent/bootstrap/tokens'
SCRIPT_URL = '/api/endpoints/siteagent/bootstrap.ps1' SCRIPT_URL = '/api/endpoints/siteagent/bootstrap.ps1'
PACKAGE_URL = '/api/endpoints/siteagent/bootstrap/package' PACKAGE_URL = '/api/endpoints/siteagent/bootstrap/package'
BASE_URL = 'https://shopdb.test/shopdb' BASE_URL = 'https://shopdb.test/shopdb'
TEMP_SCRIPT = r'C:\Windows\Temp\shopdb-bootstrap.ps1'
def _expected_command(secret, base=BASE_URL):
"""Download, run, delete. Never `irm | iex` - Defender kills that shape."""
return ('powershell -ExecutionPolicy Bypass -c "'
"try {{ Invoke-WebRequest -UseBasicParsing "
"'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}' "
"-OutFile '{2}'; & '{2}' }} finally {{ "
"Remove-Item '{2}' -Force -ErrorAction SilentlyContinue }}\""
).format(base, secret, TEMP_SCRIPT)
def _make_cert(): def _make_cert():
@@ -98,10 +109,10 @@ def test_generate_returns_the_secret_and_the_ready_made_command(
secret = data['secret'] secret = data['secret']
assert secret.startswith('sdbb_') assert secret.startswith('sdbb_')
assert len(secret) == len('sdbb_') + 43 assert len(secret) == len('sdbb_') + 43
assert data['command'] == ( assert data['command'] == _expected_command(secret)
'powershell -ExecutionPolicy Bypass -c "irm ' assert data['scripturl'] == (
'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1} | iex"'.format( '{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}'.format(BASE_URL,
BASE_URL, secret)) secret))
token = data['token'] token = data['token']
assert token['tokenprefix'] == secret[:12] assert token['tokenprefix'] == secret[:12]
assert token['maxuses'] == 3 assert token['maxuses'] == 3
@@ -111,6 +122,29 @@ def test_generate_returns_the_secret_and_the_ready_made_command(
assert token['createdby'] == 'testadmin' assert token['createdby'] == 'testadmin'
def test_the_command_downloads_and_runs_instead_of_piping_into_iex(
client, db, auth_headers):
"""Defender detects `irm ... | iex` as Trojan:Win32/Commando.A!ml and kills
the process before a line of it runs, so the published command downloads to
a file, runs it and removes it. It also stays one pasteable line, and holds
no `$`: pasted into a PowerShell prompt, the parent would expand those
before the child process ever saw them."""
data = _secret(client, auth_headers)
command = data['command']
assert ' | iex' not in command
assert 'iex' not in command.lower()
assert '$' not in command
assert '\n' not in command
assert data['scripturl'] in command
assert data['secret'] in command
assert "Remove-Item '{0}' -Force".format(TEMP_SCRIPT) in command
assert 'finally' in command
# The link the UI offers instead is the route that really serves it.
assert client.get(SCRIPT_URL,
query_string={'t': data['secret']}).status_code == 200
def test_defaults_are_a_day_and_twenty_five_uses(client, db, auth_headers): def test_defaults_are_a_day_and_twenty_five_uses(client, db, auth_headers):
token = _secret(client, auth_headers)['token'] token = _secret(client, auth_headers)['token']
assert token['maxuses'] == 25 assert token['maxuses'] == 25
@@ -383,6 +417,7 @@ def test_the_command_falls_back_to_the_request_when_no_site_url_is_set(
client, db, auth_headers): client, db, auth_headers):
Setting.set('site_base_url', '', valuetype='string') Setting.set('site_base_url', '', valuetype='string')
data = _secret(client, auth_headers) data = _secret(client, auth_headers)
assert data['command'].startswith( assert data['command'] == _expected_command(data['secret'],
'powershell -ExecutionPolicy Bypass -c "irm https://localhost' base='https://localhost')
'/api/endpoints/siteagent/bootstrap.ps1?t=') assert data['scripturl'].startswith(
'https://localhost/api/endpoints/siteagent/bootstrap.ps1?t=')

View File

@@ -9,7 +9,7 @@ different PC is refused (ASSET_MISMATCH), the shared key is refused for that PC
import pytest import pytest
from shopdb.core.models import Asset, AssetType, Setting from shopdb.core.models import Asset, AssetType, AuditLog, Setting
COLLECTOR_KEY = 'testcollectorkey' COLLECTOR_KEY = 'testcollectorkey'
@@ -732,6 +732,137 @@ def test_enrollment_pending_count_includes_unmatched_requests(
assert states.count('pending') == data['counts']['pendingcomputers'] assert states.count('pending') == data['counts']['pendingcomputers']
# -- approve binds the hostname ----------------------------------------------
#
# A one-off PC installed by the bootstrap installer is pending for `unknown
# hostname` by definition. Approving used to set matchedassetid only, so the
# hostname the agent sends resolved to nothing, bind_hostname compared that with
# the credential's asset and answered 403 ASSET_MISMATCH on every call - the PC
# enrolled and then never worked until someone typed the hostname in by hand.
def _forget_hostname(db, assetid):
from plugins.computers.models import Computer
Computer.query.filter_by(assetid=assetid).one().hostname = None
db.session.commit()
def _hostname(assetid):
from plugins.computers.models import Computer
return Computer.query.filter_by(assetid=assetid).one().hostname
def _approve(client, auth_headers, requestid, assetid, **body):
return client.post(
f'/api/endpoints/enrollment/requests/{requestid}/approve',
json=dict(assetid=assetid, **body), headers=auth_headers)
def _computer_audits():
return AuditLog.query.filter_by(entitytype='Computer').all()
def test_approve_records_the_hostname_on_a_computer_that_has_none(
client, db, app, auth_headers):
assetid = _computer(db, 'PLACEHOLDER', serialnumber='SER-9')
_forget_hostname(db, assetid)
pending = _enroll(client, {}, hostname='LOANER-07', serialnumber='SER-9')
assert pending.get_json()['data']['reason'] == 'unknown hostname'
requestid = pending.get_json()['data']['requestid']
approved = _approve(client, auth_headers, requestid, assetid)
assert approved.status_code == 200, approved.get_data(as_text=True)
assert approved.get_json()['data']['hostnameresult'] == 'set'
assert _hostname(assetid) == 'LOANER-07'
audit = _computer_audits()
assert len(audit) == 1
assert audit[0].changes['hostname'] == {'old': None, 'new': 'LOANER-07'}
# The whole point: the PC collects its credential and is then authorized.
secret = _credential(client, {}, hostname='LOANER-07',
serialnumber='SER-9')
works = client.get('/api/endpoints/alerts?hostname=LOANER-07',
headers={'X-Endpoint-Credential': secret})
assert works.status_code == 200, works.get_data(as_text=True)
def test_approve_refuses_to_rename_a_computer_without_being_told_to(
client, db, app, auth_headers):
from plugins.endpoints.models import EndpointEnrollRequest
assetid = _computer(db, 'BAY-09', serialnumber='SER-9')
pending = _enroll(client, {}, hostname='LOANER-07', serialnumber='SER-9')
requestid = pending.get_json()['data']['requestid']
refused = _approve(client, auth_headers, requestid, assetid)
assert refused.status_code == 409
error = refused.get_json()['data']['error']
assert 'BAY-09' in error['message'] and 'LOANER-07' in error['message']
assert error['details']['hostnameconflict'] is True
# Nothing moved: not the hostname, not the request.
assert _hostname(assetid) == 'BAY-09'
assert _computer_audits() == []
row = db.session.get(EndpointEnrollRequest, requestid)
assert row.status == EndpointEnrollRequest.STATUS_PENDING
assert row.matchedassetid is None
def test_approve_with_sethostname_overwrites_and_audits_the_rename(
client, db, app, auth_headers):
assetid = _computer(db, 'BAY-09', serialnumber='SER-9')
pending = _enroll(client, {}, hostname='LOANER-07', serialnumber='SER-9')
requestid = pending.get_json()['data']['requestid']
approved = _approve(client, auth_headers, requestid, assetid,
sethostname=True)
assert approved.status_code == 200, approved.get_data(as_text=True)
assert approved.get_json()['data']['hostnameresult'] == 'overwritten'
assert _hostname(assetid) == 'LOANER-07'
audit = _computer_audits()
assert len(audit) == 1
assert audit[0].changes['hostname'] == {'old': 'BAY-09',
'new': 'LOANER-07'}
assert audit[0].username == 'testadmin'
assert _approve(client, auth_headers, requestid, assetid,
sethostname='yes').status_code == 400
def test_approve_onto_a_matching_hostname_changes_nothing(
client, db, app, keyheaders, auth_headers):
assetid = _computer(db, 'BAY-01')
_setting(db, 'plugin.endpoints.autoenroll', False)
pending = _enroll(client, keyheaders)
requestid = pending.get_json()['data']['requestid']
approved = _approve(client, auth_headers, requestid, assetid)
assert approved.status_code == 200, approved.get_data(as_text=True)
assert approved.get_json()['data']['hostnameresult'] == 'unchanged'
assert _hostname(assetid) == 'BAY-01'
assert _computer_audits() == []
def test_a_hostname_on_no_computer_record_is_refused_but_named_as_such(
client, db, app, keyheaders):
"""Same code and status, an honest message: the credential is good and the
hostname is simply on no record, which is a server-side data problem, not a
PC claiming to be another one."""
assetid = _computer(db, 'BAY-01')
secret = _credential(client, keyheaders)
_forget_hostname(db, assetid)
response = client.get('/api/endpoints/alerts?hostname=BAY-01',
headers={'X-Endpoint-Credential': secret})
assert response.status_code == 403
assert _code(response) == 'ASSET_MISMATCH'
message = response.get_json()['data']['error']['message']
assert 'BAY-01' in message
assert 'no computer record' in message.lower()
def test_approve_refuses_an_enrolled_computer(client, db, app, keyheaders, def test_approve_refuses_an_enrolled_computer(client, db, app, keyheaders,
auth_headers): auth_headers):
assetid = _computer(db, 'BAY-01') assetid = _computer(db, 'BAY-01')