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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user