Make the one-line install survive Defender, and approval leave a PC that works
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:
@@ -13,9 +13,10 @@ namespace ShopDbEndpointAgent.Core;
|
||||
// unenrolled or pending PC keeps reporting.
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// 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";
|
||||
|
||||
private static readonly TimeSpan PendingRetry = TimeSpan.FromMinutes(15);
|
||||
private static readonly TimeSpan PendingJitter = TimeSpan.FromMinutes(2);
|
||||
// Retry while pending, by how long this request has been pending. An admin
|
||||
// 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[] ErrorBackoff =
|
||||
{
|
||||
@@ -85,6 +94,10 @@ public sealed class EnrollmentService : BackgroundService
|
||||
{
|
||||
int failures = 0;
|
||||
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);
|
||||
|
||||
try
|
||||
@@ -93,6 +106,8 @@ public sealed class EnrollmentService : BackgroundService
|
||||
{
|
||||
if (credentials.Current is not null)
|
||||
{
|
||||
pendingSince = null;
|
||||
pendingRequest = null;
|
||||
firstAttempt.TrySetResult();
|
||||
await wake.WaitAsync(stoppingToken);
|
||||
continue;
|
||||
@@ -103,7 +118,7 @@ public sealed class EnrollmentService : BackgroundService
|
||||
await Task.Delay(MinWakeInterval - sinceLast, stoppingToken);
|
||||
|
||||
lastAttempt = DateTime.UtcNow;
|
||||
var outcome = await TryEnrollAsync(client, stoppingToken);
|
||||
var (outcome, requestId) = await TryEnrollAsync(client, stoppingToken);
|
||||
firstAttempt.TrySetResult();
|
||||
|
||||
TimeSpan delay;
|
||||
@@ -111,14 +126,24 @@ public sealed class EnrollmentService : BackgroundService
|
||||
{
|
||||
case EnrollOutcome.Enrolled:
|
||||
failures = 0;
|
||||
pendingSince = null;
|
||||
pendingRequest = null;
|
||||
continue;
|
||||
case EnrollOutcome.Pending:
|
||||
failures = 0;
|
||||
delay = PendingRetry + TimeSpan.FromSeconds(
|
||||
(jitter.NextDouble() * 2 - 1) * PendingJitter.TotalSeconds);
|
||||
if (pendingSince is null
|
||||
|| (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;
|
||||
case EnrollOutcome.Unsupported:
|
||||
failures = 0;
|
||||
pendingSince = null;
|
||||
pendingRequest = null;
|
||||
delay = UnsupportedRetry;
|
||||
break;
|
||||
default:
|
||||
@@ -142,14 +167,26 @@ public sealed class EnrollmentService : BackgroundService
|
||||
|
||||
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 serial = ReadBiosSerial();
|
||||
if (string.IsNullOrWhiteSpace(serial))
|
||||
{
|
||||
logger.LogError("Enrollment skipped: no BIOS serial number from WMI Win32_BIOS.");
|
||||
return EnrollOutcome.Failed;
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
|
||||
string nonce;
|
||||
@@ -160,7 +197,7 @@ public sealed class EnrollmentService : BackgroundService
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Enrollment skipped: could not read or create enroll.json.");
|
||||
return EnrollOutcome.Failed;
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
|
||||
var request = new
|
||||
@@ -183,7 +220,7 @@ public sealed class EnrollmentService : BackgroundService
|
||||
logger.LogInformation(
|
||||
"Server has no enrollment endpoint (HTTP 404); staying on the shared key, retry in {Delay}.",
|
||||
UnsupportedRetry);
|
||||
return EnrollOutcome.Unsupported;
|
||||
return (EnrollOutcome.Unsupported, null);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Created)
|
||||
@@ -193,7 +230,7 @@ public sealed class EnrollmentService : BackgroundService
|
||||
if (secret is null || !secret.StartsWith("sdbe_", StringComparison.Ordinal))
|
||||
{
|
||||
logger.LogError("Enrollment returned 201 without a usable credential.");
|
||||
return EnrollOutcome.Failed;
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
|
||||
var credential = new AgentCredential
|
||||
@@ -214,18 +251,19 @@ public sealed class EnrollmentService : BackgroundService
|
||||
"Enrolled as asset {AssetId} but could not store credential {Prefix}. " +
|
||||
"Revoke it in ShopDB so this PC can enroll again.",
|
||||
credential.AssetId, credential.Prefix);
|
||||
return EnrollOutcome.Failed;
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
return EnrollOutcome.Enrolled;
|
||||
return (EnrollOutcome.Enrolled, null);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
var result = ParseResult(body);
|
||||
var requestId = result.GetInt("requestid");
|
||||
logger.LogInformation(
|
||||
"Enrollment pending (request {RequestId}): {Reason}.",
|
||||
result.GetInt("requestid"), result.GetString("reason") ?? "no reason given");
|
||||
return EnrollOutcome.Pending;
|
||||
requestId, result.GetString("reason") ?? "no reason given");
|
||||
return (EnrollOutcome.Pending, requestId);
|
||||
}
|
||||
|
||||
var refusal = AgentServerError.TryParse(body);
|
||||
@@ -234,19 +272,20 @@ public sealed class EnrollmentService : BackgroundService
|
||||
logger.LogError(
|
||||
"Enrollment refused: the approved request belongs to a different enroll nonce. " +
|
||||
"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}",
|
||||
(int)response.StatusCode, refusal?.Code, refusal?.Message);
|
||||
return EnrollOutcome.Failed;
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
catch (Exception error) when (
|
||||
error is HttpRequestException or TaskCanceledException or JsonException
|
||||
&& !token.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning("Enrollment request failed: {Message}", error.Message);
|
||||
return EnrollOutcome.Failed;
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -621,9 +621,9 @@ request row per hostname and serial (case-insensitive); repeated calls update it
|
||||
| Status | Body | Agent action |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 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
|
||||
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 |
|
||||
| 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
|
||||
as `/api/collector/endpoints`), `GET /api/endpoints/monitors/resolved`,
|
||||
`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,
|
||||
including requests that match no computer (unknown hostname) and requests from
|
||||
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/assets/<assetid>/revoke` (un-enroll). Optional
|
||||
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:
|
||||
|
||||
```
|
||||
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
|
||||
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
|
||||
@@ -785,7 +821,9 @@ admin approves it.
|
||||
Admin routes (admin role, audited):
|
||||
|
||||
- `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
|
||||
`tokenprefix` are stored.
|
||||
- `GET /api/endpoints/siteagent/bootstrap/tokens` - prefix, created, expires,
|
||||
|
||||
@@ -155,6 +155,23 @@ def asset_mismatch_response():
|
||||
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():
|
||||
"""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).
|
||||
|
||||
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
|
||||
route to answer); an enrolled PC is refused.
|
||||
"""
|
||||
hostname = (hostname or '').strip()
|
||||
if is_credential_caller():
|
||||
if hostname and resolve_hostname(hostname) != g.agentassetid:
|
||||
return None, asset_mismatch_response()
|
||||
if hostname:
|
||||
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
|
||||
|
||||
if not hostname:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""One-line bootstrap installer for the endpoint agent.
|
||||
|
||||
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:
|
||||
|
||||
powershell -ExecutionPolicy Bypass -c "irm <base>/api/endpoints/siteagent/bootstrap.ps1?t=<token> | iex"
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -61,6 +60,13 @@ LIST_LIMIT = 100
|
||||
SCRIPT_PATH = '/api/endpoints/siteagent/bootstrap.ps1'
|
||||
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
|
||||
# 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
|
||||
@@ -145,11 +151,31 @@ def bootstrap_base_url():
|
||||
return 'https://{0}{1}'.format(host, root)
|
||||
|
||||
|
||||
def bootstrap_command(secret, baseurl=None):
|
||||
"""The one line a tech pastes into an elevated PowerShell."""
|
||||
def bootstrap_script_url(secret, baseurl=None):
|
||||
"""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('/')
|
||||
return ('powershell -ExecutionPolicy Bypass -c "irm {0}{1}?t={2} | iex"'
|
||||
.format(base, SCRIPT_PATH, secret))
|
||||
return '{0}{1}?t={2}'.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):
|
||||
@@ -448,6 +474,7 @@ def create_bootstrap_token():
|
||||
'token': row.to_dict(now),
|
||||
'secret': secret,
|
||||
'command': bootstrap_command(secret),
|
||||
'scripturl': bootstrap_script_url(secret),
|
||||
},
|
||||
message='Bootstrap token created. The command is shown once.',
|
||||
http_code=201)
|
||||
@@ -487,7 +514,10 @@ def revoke_bootstrap_token(tokenid):
|
||||
|
||||
@endpoints_bp.route('/siteagent/bootstrap.ps1', methods=['GET'])
|
||||
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
|
||||
twice, or a shell that retries, must not burn the budget the package
|
||||
|
||||
@@ -65,6 +65,19 @@ REASON_NONCE_CHANGED = 'nonce changed'
|
||||
REASON_REJECTED = 'rejected'
|
||||
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',
|
||||
'notreporting')
|
||||
ENROLLMENT_STATES = ('enrolled', 'notenrolled', 'pending', 'revoked')
|
||||
@@ -557,7 +570,21 @@ def get_enrollment():
|
||||
methods=['POST'])
|
||||
@require_role('admin')
|
||||
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)
|
||||
if row is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
@@ -572,8 +599,13 @@ def approve_enroll_request(requestid):
|
||||
if isinstance(assetid, bool) or not isinstance(assetid, int):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'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()
|
||||
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,
|
||||
f'Asset {assetid} is not a computer')
|
||||
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; '
|
||||
'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.matchedassetid = assetid
|
||||
row.reason = None
|
||||
@@ -592,9 +653,12 @@ def approve_enroll_request(requestid):
|
||||
row.decidedby = _username()
|
||||
AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid,
|
||||
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()
|
||||
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',
|
||||
|
||||
@@ -262,7 +262,12 @@ Repeat until **Ready** is 0.
|
||||
mismatch, duplicate or unknown hostname, already enrolled, or auto-enroll
|
||||
off). Credentials tab -> Requests -> **Approve**; the picker defaults to the
|
||||
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
|
||||
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
|
||||
@@ -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:
|
||||
|
||||
```
|
||||
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
|
||||
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
|
||||
enroll and **waits for approval**. Approve it in Enrollment -> Credentials ->
|
||||
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
|
||||
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
|
||||
|
||||
@@ -458,6 +458,18 @@
|
||||
{{ bootstrapcopied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</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">
|
||||
Expires {{ formatDate(bootstraptoken.expiresat) }}.
|
||||
{{ bootstraptoken.remaining }} of
|
||||
@@ -745,6 +757,7 @@ const bootstraphours = ref(24)
|
||||
const bootstrapmaxuses = ref(25)
|
||||
const bootstrapbusy = ref(false)
|
||||
const bootstrapcommand = ref('')
|
||||
const bootstrapscripturl = ref('')
|
||||
const bootstraptoken = ref(null)
|
||||
const bootstrapcopied = ref(false)
|
||||
const bootstraptokens = ref([])
|
||||
@@ -880,6 +893,7 @@ async function generateBootstrapToken() {
|
||||
bootstrapmessage.value = ''
|
||||
bootstraperror.value = ''
|
||||
bootstrapcommand.value = ''
|
||||
bootstrapscripturl.value = ''
|
||||
bootstraptoken.value = null
|
||||
bootstrapcopied.value = false
|
||||
try {
|
||||
@@ -889,6 +903,7 @@ async function generateBootstrapToken() {
|
||||
})
|
||||
const data = response.data.data || {}
|
||||
bootstrapcommand.value = data.command || ''
|
||||
bootstrapscripturl.value = data.scripturl || ''
|
||||
bootstraptoken.value = data.token || null
|
||||
bootstrapmessage.value = 'Install command generated.'
|
||||
await loadBootstrapTokens()
|
||||
|
||||
@@ -330,9 +330,13 @@
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
The PC receives its credential the next time it asks,
|
||||
which is within 15 minutes. Only the PC that made this
|
||||
request can collect it.
|
||||
The PC receives its credential the next time it asks:
|
||||
about a minute for one that started asking recently,
|
||||
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>
|
||||
</td>
|
||||
</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() {
|
||||
const current = approving.value
|
||||
if (!current || !current.assetid) return
|
||||
@@ -783,13 +809,24 @@ async function approve() {
|
||||
+ `${option ? option.label : 'asset ' + current.assetid}?`)) return
|
||||
busy.value = true
|
||||
try {
|
||||
await api.post(`/endpoints/enrollment/requests/${current.requestid}/approve`,
|
||||
{ assetid: current.assetid })
|
||||
toast.success(`Approved ${current.hostname}`)
|
||||
approving.value = null
|
||||
await loadEnrollment({ quiet: true })
|
||||
await postApprove(current, false)
|
||||
} 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 {
|
||||
busy.value = false
|
||||
}
|
||||
|
||||
@@ -19,9 +19,14 @@ import AgentKeySettings from './AgentKeySettings.vue'
|
||||
|
||||
const TOKENS_URL = '/endpoints/siteagent/bootstrap/tokens'
|
||||
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 =
|
||||
'powershell -ExecutionPolicy Bypass -c "irm https://shopdb.test/shopdb'
|
||||
+ `/api/endpoints/siteagent/bootstrap.ps1?t=${SECRET} | iex"`
|
||||
'powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest '
|
||||
+ `-UseBasicParsing '${SCRIPT_URL}' -OutFile '${TEMP}'; & '${TEMP}' }`
|
||||
+ ` finally { Remove-Item '${TEMP}' -Force -ErrorAction SilentlyContinue }"`
|
||||
|
||||
const MINTED_TOKEN = {
|
||||
tokenid: 7,
|
||||
@@ -81,7 +86,7 @@ describe('AgentKeySettings one-line install', () => {
|
||||
|
||||
it('generate posts the hours and maximum uses', async () => {
|
||||
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()
|
||||
|
||||
@@ -95,7 +100,7 @@ describe('AgentKeySettings one-line install', () => {
|
||||
|
||||
it('defaults to 24 hours and 25 uses', async () => {
|
||||
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()
|
||||
|
||||
@@ -109,7 +114,7 @@ describe('AgentKeySettings one-line install', () => {
|
||||
|
||||
it('renders the returned command, the once-only warning and the expiry', async () => {
|
||||
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()
|
||||
await generateButton(wrapper).trigger('click')
|
||||
@@ -126,11 +131,27 @@ describe('AgentKeySettings one-line install', () => {
|
||||
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 () => {
|
||||
const writeText = vi.fn(() => Promise.resolve())
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } })
|
||||
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()
|
||||
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 () => {
|
||||
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()
|
||||
await generateButton(first).trigger('click')
|
||||
|
||||
@@ -189,6 +189,69 @@ describe('EnrollmentSettings', () => {
|
||||
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 () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
const wrapper = await render()
|
||||
|
||||
@@ -27,6 +27,17 @@ TOKENS_URL = '/api/endpoints/siteagent/bootstrap/tokens'
|
||||
SCRIPT_URL = '/api/endpoints/siteagent/bootstrap.ps1'
|
||||
PACKAGE_URL = '/api/endpoints/siteagent/bootstrap/package'
|
||||
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():
|
||||
@@ -98,10 +109,10 @@ def test_generate_returns_the_secret_and_the_ready_made_command(
|
||||
secret = data['secret']
|
||||
assert secret.startswith('sdbb_')
|
||||
assert len(secret) == len('sdbb_') + 43
|
||||
assert data['command'] == (
|
||||
'powershell -ExecutionPolicy Bypass -c "irm '
|
||||
'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1} | iex"'.format(
|
||||
BASE_URL, secret))
|
||||
assert data['command'] == _expected_command(secret)
|
||||
assert data['scripturl'] == (
|
||||
'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}'.format(BASE_URL,
|
||||
secret))
|
||||
token = data['token']
|
||||
assert token['tokenprefix'] == secret[:12]
|
||||
assert token['maxuses'] == 3
|
||||
@@ -111,6 +122,29 @@ def test_generate_returns_the_secret_and_the_ready_made_command(
|
||||
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):
|
||||
token = _secret(client, auth_headers)['token']
|
||||
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):
|
||||
Setting.set('site_base_url', '', valuetype='string')
|
||||
data = _secret(client, auth_headers)
|
||||
assert data['command'].startswith(
|
||||
'powershell -ExecutionPolicy Bypass -c "irm https://localhost'
|
||||
'/api/endpoints/siteagent/bootstrap.ps1?t=')
|
||||
assert data['command'] == _expected_command(data['secret'],
|
||||
base='https://localhost')
|
||||
assert data['scripturl'].startswith(
|
||||
'https://localhost/api/endpoints/siteagent/bootstrap.ps1?t=')
|
||||
|
||||
@@ -9,7 +9,7 @@ different PC is refused (ASSET_MISMATCH), the shared key is refused for that PC
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, Setting
|
||||
from shopdb.core.models import Asset, AssetType, AuditLog, Setting
|
||||
|
||||
|
||||
COLLECTOR_KEY = 'testcollectorkey'
|
||||
@@ -732,6 +732,137 @@ def test_enrollment_pending_count_includes_unmatched_requests(
|
||||
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,
|
||||
auth_headers):
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
|
||||
Reference in New Issue
Block a user