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

@@ -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:

View File

@@ -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

View File

@@ -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',