Merge remote-tracking branch 'origin/wj' into wilm

This commit is contained in:
benjamin.baker
2026-09-18 17:25:24 -04:00
64 changed files with 35788 additions and 20614 deletions

View File

@@ -9,9 +9,15 @@ and, when the route names a PC, binds the caller through bind_hostname().
X-API-Key The shared fleet key (or a collector-scoped managed
token). Accepted only while
plugin.endpoints.sharedkeyallowed is on, and never for a
PC that holds an active credential. A PC blocked
from re-enrolling has no active credential, so an
older agent on it keeps working on the shared key.
PC that holds an active credential. A PC whose
credential was REVOKED falls back to the shared key,
which is how a rollback to an older agent works and how
the un-migrated fleet keeps reporting; a site that has
finished migrating turns
plugin.endpoints.revokeddeniessharedkey on and a revoked
PC is then refused until it enrolls again. A PC that
never enrolled has no credential row at all and is
never touched by either rule.
Only the sha256 of a credential is stored. The full secret exists once, in the
enroll response.
@@ -37,6 +43,12 @@ DISPLAY_PREFIX_LENGTH = 12
SHARED_KEY_SETTING = 'plugin.endpoints.sharedkeyallowed'
AUTO_ENROLL_SETTING = 'plugin.endpoints.autoenroll'
# Off by default, which is what this site does today: revoking a credential
# drops the PC back to the shared key. Turning it on makes a revoke a real
# eviction - the PC reports nothing until it enrolls again - so it is only safe
# once the fleet is off the shared key, and it also closes the rollback path
# that revoke + blockreenroll exists for.
REVOKED_DENIES_SHARED_KEY_SETTING = 'plugin.endpoints.revokeddeniessharedkey'
CODE_CREDENTIAL_INVALID = 'CREDENTIAL_INVALID'
CODE_SHARED_KEY_REFUSED = 'SHARED_KEY_REFUSED'
@@ -70,6 +82,12 @@ def auto_enroll_enabled():
return Setting.get(AUTO_ENROLL_SETTING, default=True) is not False
def revoked_denies_shared_key():
"""True when a revoked credential also bars the PC from the shared key."""
return Setting.get(REVOKED_DENIES_SHARED_KEY_SETTING,
default=False) is True
def active_credential_for(assetid):
from ..models import EndpointCredential
@@ -81,6 +99,33 @@ def active_credential_for(assetid):
.first())
def shared_key_denied_for(assetid):
"""True when this asset may not report on the shared key.
An active credential always denies it: the PC has its own secret and a
shared-key call for it is either an older agent or somebody else.
A revoked credential denies it only while revokeddeniessharedkey is on.
Today it does not, on purpose: revoke is also how an admin rotates a
credential or rolls a PC back to an agent that only knows the shared key
(revoke + blockreenroll), and denying by default would silence that PC
instead. A PC that never enrolled has no credential row and is never denied,
which is what keeps the un-migrated fleet reporting.
"""
from ..models import EndpointCredential
if assetid is None:
return False
row = (EndpointCredential.query
.filter(EndpointCredential.assetid == assetid)
.first())
if row is None:
return False
if row.revokedat is None:
return True
return revoked_denies_shared_key()
def reenroll_blocked(assetid):
"""True when an admin blocked this asset from enrolling again."""
from ..models import EndpointCredential
@@ -130,8 +175,7 @@ def shared_key_refused_for(hostname):
"""True when the shared key may not report for this hostname."""
if not shared_key_allowed():
return True
assetid = resolve_hostname(hostname)
return assetid is not None and active_credential_for(assetid) is not None
return shared_key_denied_for(resolve_hostname(hostname))
def credential_invalid_response():
@@ -148,6 +192,20 @@ def shared_key_refused_response():
http_code=401)
def shared_key_revoked_response():
"""Same code and status; the operator needs to know which refusal it is.
The PC holds no credential and cannot use the shared key either, so an
admin has to let it enroll again. Nothing changes for the agent, which
keeps retrying either way.
"""
return error_response(
CODE_SHARED_KEY_REFUSED,
'This endpoint had its credential revoked, so the shared key is not '
'accepted for it; let it enroll again',
http_code=401)
def asset_mismatch_response():
return error_response(
CODE_ASSET_MISMATCH,
@@ -210,7 +268,8 @@ def bind_hostname(hostname):
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.
route to answer); an enrolled PC is refused, and so is one whose credential
was revoked while revokeddeniessharedkey is on.
"""
hostname = (hostname or '').strip()
if is_credential_caller():
@@ -225,7 +284,9 @@ def bind_hostname(hostname):
if not hostname:
return None, None
assetid = resolve_hostname(hostname)
if assetid is not None and active_credential_for(assetid) is not None:
if shared_key_denied_for(assetid):
if active_credential_for(assetid) is None:
return None, shared_key_revoked_response()
return None, shared_key_refused_response()
g.agentassetid = assetid
return assetid, None

View File

@@ -14,14 +14,25 @@ Routes
POST /siteagent/bootstrap/tokens admin, audited; secret once
GET /siteagent/bootstrap/tokens admin; never a secret
POST /siteagent/bootstrap/tokens/<id>/revoke admin, audited
GET /siteagent/bootstrap.ps1?t=... token only; not a use
GET /siteagent/bootstrap/package?t=... token only; one use
GET /siteagent/bootstrap.ps1 token only; not a use
GET /siteagent/bootstrap/package token only; one use
The two token-authenticated routes answer 404 for anything not currently
usable - unknown, expired, revoked or exhausted - so the URL confirms nothing
about which tokens exist. Both are per-IP rate limited with the same fixed
window shape the login and payload-download limiters use, and both write an
audit row carrying the caller's IP.
The two token-authenticated routes take the token in the
X-Endpoint-Bootstrap-Token header, and still accept the deprecated ?t= form so
a command already pasted on a bay keeps working. They answer 404 for anything
not currently usable - unknown, malformed, expired, revoked, exhausted, or no
token presented at all - so the URL confirms nothing about which tokens exist,
nor about whether the query form is still accepted here. The one 400 is a real
query token offered at a site that has turned the query form off.
The admin UI's Download .ps1 link has to use the query form (a browser link
cannot send a header), so its URL carries a marker that tells it apart from a
bay still running a command pasted before this change. Only the bay logs the
deprecation warning, which is what the rollout counts un-migrated bays with.
Both routes are per-IP rate limited with the same fixed window shape the login
and payload-download limiters use, and both write an audit row carrying the
caller's IP.
"""
import hashlib
@@ -60,12 +71,60 @@ 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
# Where the one-line command stages 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'
# child process sees them.
#
# NOT C:\Windows\Temp. That directory grants Users create-file and gives
# CREATOR OWNER full control of what they create, so any standard user on the
# bay can pre-plant this exact path and wait for a tech to paste the command,
# which then runs the planted content elevated. Program Files is admin-only on
# a stock install, the command re-asserts an admin-only DACL on the directory
# it creates, and it refuses to run a staged file owned by anyone else.
COMMAND_STAGE_DIR = r'C:\Program Files\ShopDB\bootstrap'
COMMAND_SCRIPT_PATH = COMMAND_STAGE_DIR + r'\shopdb-bootstrap.ps1'
# Well-known SIDs, not names: the bays are domain-joined and a renamed or
# localized Administrators group would quietly break both the grant and the
# ownership test.
ADMINISTRATORS_SID = 'S-1-5-32-544'
SYSTEM_SID = 'S-1-5-18'
# The token the two public routes accept. A header, not ?t=: a query string
# lands verbatim in the reverse proxy and gunicorn access logs, and a live
# install token has no business sitting in a log file.
BOOTSTRAP_TOKEN_HEADER = 'X-Endpoint-Bootstrap-Token'
QUERY_TOKEN_ARG = 't'
# Where the token on this request came from. Kept apart because the rollout
# counts un-migrated bays by the deprecation warning SOURCE_QUERY logs: an
# admin clicking Download .ps1 in the browser must not land in that count, and
# a request carrying no token at all must not answer differently from one
# carrying a token nobody issued.
SOURCE_HEADER = 'header'
SOURCE_QUERY = 'query'
SOURCE_UI_LINK = 'uilink'
SOURCE_NONE = 'none'
# The marker on the admin Download .ps1 link. A hint, NOT authentication: a
# caller can add it by hand, and nothing is granted by it. What matters is the
# other direction - a one-line command generated before this change cannot
# carry it, so a request without it really is a bay to go and re-issue.
UI_LINK_ARG = 'via'
UI_LINK_VALUE = 'download'
# DEPRECATED, kept on purpose: every command already pasted on a bay, and the
# UI's "Download .ps1" link (a browser link cannot send a header), carry the
# token in the query string. Accepting it stays the default so a mid-rollout
# bay keeps working; a site that has re-issued its live commands turns it off.
# Turning it off also disables the download link.
#
# Declared in the endpoints plugin's get_settings_defaults() (so the row seeds
# boolean, not string) and flipped from Settings > Endpoints > Build site agent
# > One-line install, "Accept the token in the URL". A switch with no seed and
# no control would be inert on an air-gapped box.
QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
# 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
@@ -86,6 +145,22 @@ def _naive_utcnow():
def _caller_ip():
"""The caller address for the audit rows and the deprecation warning.
Read the value before trusting it to identify a bay. client_ip() returns
remote_addr and nothing else. On the shipped Windows deploy IIS launches
waitress on loopback through HttpPlatformHandler, and deploy/windows/
web.config ships the X-Forwarded-For rewrite rule COMMENTED OUT - IIS sets
no such header on its own, so waitress's --trusted-proxy=127.0.0.1
--trusted-proxy-count=1 has nothing to rewrite REMOTE_ADDR from and leaves
it as the loopback peer. TRUSTED_PROXY_COUNT also defaults to 0, so
ProxyFix is not installed either. Every row and every warning then reads
127.0.0.1, and the un-migrated-bay list identifies no bay.
It is a real address only where the site enabled that rewrite rule (the
installer's -ClientIpSource direct), or set TRUSTED_PROXY_COUNT for a
front proxy that sets the header itself.
"""
return client_ip(request) or 'unknown'
@@ -142,11 +217,31 @@ def bootstrap_base_url():
return 'https://{0}{1}'.format(host, root)
def _base(baseurl=None):
resolved = baseurl if baseurl is not None else bootstrap_base_url()
return resolved.rstrip('/')
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 '{0}{1}?t={2}'.format(base, SCRIPT_PATH, secret)
"""The UI's "Download .ps1" link: a plain file a tech can save and run.
Still the query-string form, because an <a download> cannot set a header.
The one-line command no longer uses this shape - see bootstrap_command.
It carries UI_LINK_ARG so the server can tell this click apart from a bay
running a pre-change command. Without that the admin's own Download link
fires the deprecation warning the rollout uses to FIND un-migrated bays,
and the count becomes noise the moment an admin mints a token.
"""
return '{0}{1}?{2}={3}&{4}={5}'.format(_base(baseurl), SCRIPT_PATH,
QUERY_TOKEN_ARG, secret,
UI_LINK_ARG, UI_LINK_VALUE)
def bootstrap_fetch_url(path, baseurl=None):
"""A token-free URL for the machine-to-machine fetches. The token rides in
BOOTSTRAP_TOKEN_HEADER so it never reaches an access log."""
return '{0}{1}'.format(_base(baseurl), path)
def bootstrap_command(secret, baseurl=None):
@@ -158,28 +253,145 @@ def bootstrap_command(secret, baseurl=None):
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
and the run are wrapped in try/finally so the staged file goes away even
when the script exits non-zero or the download itself fails.
Staging is the part worth being careful about, so the line creates
COMMAND_STAGE_DIR with an admin-only DACL, clears any leftover file, and
refuses (exit 5) to run a staged script that some other principal owns -
which is what a pre-planted file looks like. Well-known SIDs throughout,
and the owner may also be the elevated caller, since a file created by an
administrator is owned by that account rather than by the group.
The hardening is also CHECKED (exit 6). icacls is a native command, so it
reports failure only in an exit code: no exception is raised, and
$ErrorActionPreference never applies to it. Unchecked, an icacls that
cannot write the DACL leaves the directory inheriting whatever the parent
grants and the line downloads into it anyway - the silent version of the
hole this staging move was made to close. The same check also covers the
New-Item before it, because icacls on a directory that was never created
exits non-zero too.
Read as `Get-Variable LASTEXITCODE -ValueOnly`, not `$LASTEXITCODE`: the
whole line must stay free of `$`, or a paste into an already-running
PowerShell expands it in the PARENT and the child is handed `-ne 0` with
the parent's last exit code baked in.
"""
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)
return (
'powershell -ExecutionPolicy Bypass -c "'
"try {{ New-Item -ItemType Directory -Force -Path '{stagedir}' "
'| Out-Null; '
"icacls '{stagedir}' /inheritance:r /grant '*{admin}:(OI)(CI)F' "
"'*{system}:(OI)(CI)F' | Out-Null; "
'if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) '
"{{ Write-Host 'Refusing to run: could not restrict "
"{stagedir} to administrators (icacls failed). Fix the directory "
"or delete it, then run the command again.' -ForegroundColor Red; "
'exit 6 }}; '
"Remove-Item '{path}' -Force -ErrorAction SilentlyContinue; "
"Invoke-WebRequest -UseBasicParsing '{url}' "
"-Headers @{{'{header}'='{secret}'}} -OutFile '{path}'; "
"if ((Get-Acl '{path}').GetOwner("
'[Security.Principal.SecurityIdentifier]).Value -notin '
"@('{admin}','{system}',"
'[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) '
"{{ Write-Host 'Refusing to run: the staged bootstrap script is not "
"owned by an administrator.' -ForegroundColor Red; exit 5 }}; "
"& '{path}' }} finally {{ Remove-Item '{path}' -Force "
'-ErrorAction SilentlyContinue }}"'
).format(stagedir=COMMAND_STAGE_DIR,
path=COMMAND_SCRIPT_PATH,
url=bootstrap_fetch_url(SCRIPT_PATH, baseurl),
header=BOOTSTRAP_TOKEN_HEADER,
secret=secret,
admin=ADMINISTRATORS_SID,
system=SYSTEM_SID)
def _hash_token(secret):
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
def _resolve_token():
"""The usable token named by ?t=, else None.
def _presented_token():
"""(secret, source) for the token this request carries.
Header first, query string second. The query form is deprecated but still
the only one a browser download link can use.
SOURCE_NONE when nothing was presented, which is NOT the same as an empty
query token: reporting a bare GET as 'query' made it answer 400 naming the
header, telling an anonymous caller whether this site had turned the query
form off, and contradicting the 404-for-everything-unusable rule at the top
of this module.
"""
header = (request.headers.get(BOOTSTRAP_TOKEN_HEADER) or '').strip()
if header:
return header, SOURCE_HEADER
query = (request.args.get(QUERY_TOKEN_ARG) or '').strip()
if not query:
return '', SOURCE_NONE
if request.args.get(UI_LINK_ARG) == UI_LINK_VALUE:
return query, SOURCE_UI_LINK
return query, SOURCE_QUERY
def _is_query_source(source):
"""Both query-string forms, the bay's and the admin download link's. The
setting is about a token in an access log, and the admin link puts one
there just as surely as a pasted command does."""
return source in (SOURCE_QUERY, SOURCE_UI_LINK)
def _query_token_allowed():
"""Defaults to True: today's pasted commands and the UI download link both
use the query form, and turning that off mid-rollout strands a bay."""
return bool(Setting.get(QUERY_TOKEN_SETTING, True))
def _query_token_refused():
"""Answered before any lookup, so it confirms nothing about any token."""
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Send the bootstrap token in the {0} header.'.format(
BOOTSTRAP_TOKEN_HEADER),
http_code=400)
def _note_token_source(source):
"""Audit suffix for a token that really resolved, and, for a query-string
token, one log line naming the caller and WHICH query path it was.
Only the bay warns. That warning is the rollout's un-migrated-bay counter
(see PER-PC-CREDENTIALS-ROLLOUT.md), so the admin's own Download .ps1 link
logs at info instead - otherwise every mint-and-test by an admin adds a
bay that does not exist.
Never the token itself, in either line: keeping it out of the logs is the
whole point of the header form.
"""
if source == SOURCE_UI_LINK:
current_app.logger.info(
'bootstrap script fetched by %s from the admin Download .ps1 '
'link; query-string token, but the admin browser path, NOT an '
'un-migrated bay', _caller_ip())
return ' (query-string token, admin download link)'
if source == SOURCE_QUERY:
current_app.logger.warning(
'bootstrap token presented in the query string by %s with no '
'admin download marker, so this is an un-migrated bay running a '
'command pasted before the header form; that form is deprecated '
'and slated for removal, re-issue the one-line install command',
_caller_ip())
return ' (query-string token, deprecated)'
return ''
def _resolve_token(secret):
"""The usable token with this secret, else None.
Unknown, malformed, expired, revoked and exhausted all resolve to None so
the caller can answer 404 for every one of them.
"""
secret = (request.args.get('t') or '').strip()
if not _TOKEN_PATTERN.match(secret):
return None
row = EndpointBootstrapToken.query.filter_by(
@@ -218,6 +430,7 @@ _SCRIPT_TEMPLATE = '''# ShopDB endpoint agent bootstrap
$ErrorActionPreference = "Stop"
$BootstrapPackageUrl = {packageurl}
$BootstrapPackageHeaders = @{{ {tokenheader} = {token} }}
$BootstrapServiceName = "ShopDbEndpointAgent"
$BootstrapInstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent"
$BootstrapAgentSha256 = {agentsha}
@@ -308,7 +521,7 @@ try {{
New-Item -ItemType Directory -Force -Path $bootstrapTempDir | Out-Null
Write-Host "[1/4] Downloading the agent package ..."
Invoke-WebRequest -Uri $BootstrapPackageUrl -OutFile $bootstrapZipPath -UseBasicParsing
Invoke-WebRequest -Uri $BootstrapPackageUrl -Headers $BootstrapPackageHeaders -OutFile $bootstrapZipPath -UseBasicParsing
Write-Host "[2/4] Expanding and verifying ..."
Expand-Archive -Path $bootstrapZipPath -DestinationPath $bootstrapExtractDir -Force
@@ -394,11 +607,12 @@ exit 0
def build_bootstrap_script(secret, baseurl=None):
"""The PowerShell text served for one token, with the URL and hashes baked in."""
base = (baseurl if baseurl is not None else bootstrap_base_url()).rstrip('/')
base = _base(baseurl)
hashes = siteagent.staged_binary_hashes()
return _SCRIPT_TEMPLATE.format(
packageurl=json.dumps('{0}{1}?t={2}'.format(base, PACKAGE_PATH,
secret)),
packageurl=json.dumps(bootstrap_fetch_url(PACKAGE_PATH, base)),
tokenheader=json.dumps(BOOTSTRAP_TOKEN_HEADER),
token=json.dumps(secret),
agentsha=json.dumps(hashes.get('agent', '')),
traysha=json.dumps(hashes.get('tray', '')),
forcevariable=FORCE_VARIABLE,
@@ -500,7 +714,8 @@ def revoke_bootstrap_token(tokenid):
# ---------------------------------------------------------------------------
# Token-authenticated: no login, the token in ?t= is the whole authorization
# Token-authenticated: no login, the token is the whole authorization. It
# rides in the X-Endpoint-Bootstrap-Token header; ?t= is still accepted.
# ---------------------------------------------------------------------------
@endpoints_bp.route('/siteagent/bootstrap.ps1', methods=['GET'])
@@ -516,12 +731,15 @@ def serve_bootstrap_script():
"""
if _ratelimited():
return _rate_limit_response()
row = _resolve_token()
secret, source = _presented_token()
if _is_query_source(source) and not _query_token_allowed():
return _query_token_refused()
row = _resolve_token(secret)
if row is None:
return _not_found()
secret = (request.args.get('t') or '').strip()
_audit('requested', row, 'bootstrap script served')
_audit('requested', row,
'bootstrap script served' + _note_token_source(source))
db.session.commit()
return Response(build_bootstrap_script(secret), mimetype='text/plain')
@@ -536,7 +754,10 @@ def download_bootstrap_package():
"""
if _ratelimited():
return _rate_limit_response()
row = _resolve_token()
secret, source = _presented_token()
if _is_query_source(source) and not _query_token_allowed():
return _query_token_refused()
row = _resolve_token(secret)
if row is None:
return _not_found()
@@ -544,8 +765,8 @@ def download_bootstrap_package():
row.usecount = (row.usecount or 0) + 1
row.lastusedat = now
row.lastusedip = _caller_ip()[:45]
_audit('downloaded', row, 'bootstrap package, use {0} of {1}'.format(
row.usecount, row.maxuses))
_audit('downloaded', row, 'bootstrap package, use {0} of {1}{2}'.format(
row.usecount, row.maxuses, _note_token_source(source)))
db.session.commit()
buffer, warning = siteagent.build_keyless_package(bootstrap_base_url())

View File

@@ -78,6 +78,14 @@ ENROLL_RATELIMIT_WINDOW_SECONDS = 60
PENDING_CAP_SETTING = 'plugin.endpoints.maxpendingenrollments'
DEFAULT_PENDING_CAP = 500
# How long a pending request may go untouched before the cap treats it as
# abandoned. An agent waiting for approval re-asks about once a minute, so a row
# nothing has touched for a week is a PC that was re-imaged, renamed or scrapped,
# or a junk row somebody wrote at the open door. At the cap those are dropped to
# make room before a PC nobody has seen before is refused, so a queue that filled
# with abandoned rows drains itself instead of blocking every new PC on site.
PENDING_STALE_AFTER = timedelta(days=7)
_NONCE_PATTERN = re.compile(r'^[A-Za-z0-9_-]{32,128}$')
REASON_UNKNOWN_HOSTNAME = 'unknown hostname'
@@ -265,6 +273,38 @@ def _pending_queue_is_full():
return pending >= cap
def _drop_stale_pending():
"""Delete pending requests nothing has touched in PENDING_STALE_AFTER.
Only PENDING rows: an admin decision (approved, rejected, issued) is never
aged out, so a rejection keeps standing however long the PC argues with it.
Returns how many rows went, for the caller to decide whether the cap is
still reached.
"""
cutoff = _naive_utcnow() - PENDING_STALE_AFTER
stale = (EndpointEnrollRequest.query
.filter(EndpointEnrollRequest.status
== EndpointEnrollRequest.STATUS_PENDING,
EndpointEnrollRequest.lastseenat < cutoff)
.order_by(EndpointEnrollRequest.lastseenat.asc())
.all())
if not stale:
return 0
for row in stale:
db.session.delete(row)
current_app.logger.info(
'endpoint enroll: aged out %d pending requests untouched for %d days',
len(stale), PENDING_STALE_AFTER.days)
AuditLog.log(
'deleted', 'EndpointEnrollRequest',
entityname=stale[0].hostname,
details='aged out {0} pending enroll requests untouched for {1} '
'days, to make room under the pending cap'.format(
len(stale), PENDING_STALE_AFTER.days))
db.session.commit()
return len(stale)
def _pending_queue_full_response(hostname, serialnumber):
"""Refuse a new row, say so plainly in the log and leave an audit trail."""
cap = pending_cap()
@@ -349,15 +389,42 @@ def enroll_endpoint():
if row is None:
# First contact for this hostname and serial. This is the only branch
# that grows the table, so it is the only one the cap guards.
# that grows the table, so it is the only one the cap guards. Every
# other contact reuses the one row this identity already owns, which is
# what keeps a single PC from holding a queue's worth of them.
if _pending_queue_is_full():
return _pending_queue_full_response(hostname, serialnumber)
# Abandoned rows come out before a real PC is turned away.
_drop_stale_pending()
if _pending_queue_is_full():
return _pending_queue_full_response(hostname, serialnumber)
row = EndpointEnrollRequest(
hostname=hostname, serialnumber=serialnumber, enrollnonce=nonce,
status=EndpointEnrollRequest.STATUS_PENDING, requestedat=now)
db.session.add(row)
else:
samenonce = hmac.compare_digest(row.enrollnonce, nonce)
if row.status == EndpointEnrollRequest.STATUS_REJECTED:
# A rejection binds to the IDENTITY - this hostname and serial - not
# to the nonce it was rejected with. A new installation on the same
# PC brings a new nonce, and re-arming the row on it would put the
# request back in the queue and, with the shared key and auto-enroll
# on, hand the retry the credential an admin had just refused.
# Nothing the PC sends clears a rejection; an admin approving the
# request does.
#
# The nonce is still recorded, because a rejected row hands nothing
# out and an admin who does approve it later has to bind that
# approval to the installation asking NOW, not to the one that was
# refused weeks ago.
row.enrollnonce = nonce
row.lastseenat = now
row.sourceip = sourceip
if agentversion:
row.agentversion = agentversion
db.session.commit()
return success_response(
{'status': 'pending', 'requestid': row.requestid,
'reason': REASON_REJECTED}, http_code=202)
if row.status == EndpointEnrollRequest.STATUS_APPROVED:
row.lastseenat = now
row.sourceip = sourceip
@@ -380,16 +447,10 @@ def enroll_endpoint():
row.lastseenat = now
row.sourceip = sourceip
return _pending(row, REASON_NONCE_CHANGED, row.matchedassetid)
if row.status == EndpointEnrollRequest.STATUS_REJECTED and samenonce:
row.lastseenat = now
row.sourceip = sourceip
db.session.commit()
return success_response(
{'status': 'pending', 'requestid': row.requestid,
'reason': REASON_REJECTED}, http_code=202)
if row.status != EndpointEnrollRequest.STATUS_PENDING:
# Issued earlier (credential since revoked) or rejected for another
# installation: a new enrollment cycle bound to this nonce.
# Issued earlier and the credential has since been revoked: a new
# enrollment cycle bound to this nonce. Rejected rows never reach
# here - they returned above, whatever nonce they carried.
row.enrollnonce = nonce
row.requestedat = now
row.decidedat = None
@@ -574,6 +635,7 @@ def _page(rows):
def _settings_payload():
return {'sharedkeyallowed': agentauth.shared_key_allowed(),
'autoenroll': agentauth.auto_enroll_enabled(),
'revokeddeniessharedkey': agentauth.revoked_denies_shared_key(),
'maxpendingenrollments': pending_cap()}
@@ -667,6 +729,11 @@ def approve_enroll_request(requestid):
The pending reason for a one-off PC is by definition `unknown hostname`, so
this is the normal case, not the exception.
Approving is also the only way out of a rejection: a rejected request keeps
refusing its PC whatever nonce it sends, and this is the deliberate admin
step that lifts it. The approval binds to the nonce of that PC's most recent
contact, so an agent reinstalled while it was refused still collects it.
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
@@ -734,6 +801,7 @@ def approve_enroll_request(requestid):
details='hostname {0} from enroll request {1}'.format(
hostnameresult, row.requestid))
wasrejected = row.status == EndpointEnrollRequest.STATUS_REJECTED
row.status = EndpointEnrollRequest.STATUS_APPROVED
row.matchedassetid = assetid
row.reason = None
@@ -741,8 +809,9 @@ def approve_enroll_request(requestid):
row.decidedby = _username()
AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid,
entityname=row.hostname,
details='enroll request approved for asset {0}, hostname {1}'
.format(assetid, hostnameresult))
details='enroll request {0} for asset {1}, hostname {2}'
.format('approved out of a rejection' if wasrejected
else 'approved', assetid, hostnameresult))
db.session.commit()
approved = row.to_dict()
approved['hostnameresult'] = hostnameresult
@@ -780,6 +849,11 @@ def revoke_endpoint_credential(assetid):
before rolling a PC back to an agent that only knows the shared key: without
it the current agent re-enrolls within seconds and the older agent is then
refused. A PC whose credential is already revoked can still be blocked.
Whether a revoked PC may still report on the shared key is the site setting
plugin.endpoints.revokeddeniessharedkey. It is off here, so today it can -
which is what makes the rollback above work, and what keeps a rotation from
silencing the PC while it waits for approval.
"""
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
@@ -853,12 +927,24 @@ def allow_endpoint_reenroll(assetid):
@endpoints_bp.route('/enrollment/settings', methods=['PUT'])
@require_role('admin')
def update_enrollment_settings():
"""Change the enrollment settings.
revokeddeniessharedkey is off by default, which is what the fleet does
today: a PC whose credential was revoked falls back to the shared key, so a
credential rotation, and the rollback to an older agent that revoke +
blockreenroll exists for, both keep reporting. Turning it on makes a revoke
an eviction - that PC reports nothing until it is allowed to enroll again -
and is only safe once every PC here holds its own credential.
"""
from shopdb.api import Setting
payload = request.get_json(silent=True) or {}
changes = {}
for key, settingkey in (('sharedkeyallowed', agentauth.SHARED_KEY_SETTING),
('autoenroll', agentauth.AUTO_ENROLL_SETTING)):
for key, settingkey in (
('sharedkeyallowed', agentauth.SHARED_KEY_SETTING),
('autoenroll', agentauth.AUTO_ENROLL_SETTING),
('revokeddeniessharedkey',
agentauth.REVOKED_DENIES_SHARED_KEY_SETTING)):
if key not in payload:
continue
if not isinstance(payload[key], bool):
@@ -878,7 +964,8 @@ def update_enrollment_settings():
if not changes:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Send sharedkeyallowed, autoenroll and/or maxpendingenrollments')
'Send sharedkeyallowed, autoenroll, revokeddeniessharedkey '
'and/or maxpendingenrollments')
before = _settings_payload()
for key, (settingkey, value, valuetype) in changes.items():

View File

@@ -410,6 +410,57 @@ function Set-HklmValueBothViews {{
}}
}}
# Lock SOFTWARE\\GE\\ShopDB to SYSTEM and Administrators, in both views.
#
# WHY. The shared fleet key is written here and stays until every consumer is
# on a per-PC credential, and the DACL this key inherits by default grants
# BUILTIN\\Users read - so any logged-on user on any bay could read the fleet
# key, which is the exact exposure the per-PC credential migration exists to
# close. Everything that legitimately reads this key runs as SYSTEM (the agent
# service, the GE-Enforce reporters) or as an admin (the adoption tool,
# Set-ShopDBServerPins.ps1). The tray reads no registry at all - it talks to the
# service over the named pipe - so nothing loses access here.
#
# It also protects ServerPins from a standard user, which is what makes the
# registry safe to treat as authoritative for pinning.
function Protect-HklmShopdbKey {{
param([Parameter(Mandatory)] [string] $SubKey)
$rights = [System.Security.AccessControl.RegistryRights]
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
[Microsoft.Win32.RegistryView]::Registry32)) {{
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {{
$key = $base.OpenSubKey(
$SubKey,
[Microsoft.Win32.RegistryKeyPermissionCheck]::ReadWriteSubTree,
$rights::ChangePermissions -bor $rights::ReadPermissions)
if (-not $key) {{ continue }}
try {{
$acl = $key.GetAccessControl(
[System.Security.AccessControl.AccessControlSections]::Access)
# Break inheritance and DROP the inherited entries rather than
# copying them in: copying is what silently preserves the
# BUILTIN\\Users read this function exists to remove.
$acl.SetAccessRuleProtection($true, $false)
foreach ($rule in @($acl.Access)) {{ $null = $acl.RemoveAccessRule($rule) }}
foreach ($sid in @("S-1-5-18", "S-1-5-32-544")) {{
$who = New-Object System.Security.Principal.SecurityIdentifier($sid)
$acl.AddAccessRule((New-Object System.Security.AccessControl.RegistryAccessRule(
$who,
$rights::FullControl,
[System.Security.AccessControl.InheritanceFlags]::ContainerInherit,
[System.Security.AccessControl.PropagationFlags]::None,
[System.Security.AccessControl.AccessControlType]::Allow)))
}}
$key.SetAccessControl($acl)
}}
finally {{ $key.Dispose() }}
}}
finally {{ $base.Dispose() }}
}}
}}
# REG_MULTI_SZ in both views, compare-before-write so a reinstall with the same
# pins leaves the value (and its last-write time) alone.
function Set-HklmMultiStringBothViews {{
@@ -492,6 +543,17 @@ else {{
Write-Warning "no server pin in this package; ServerPins left as is."
}}
# Harden the key AFTER the writes: the DACL is replaced, not merged, so writing
# first and locking second keeps this correct whether or not the key pre-existed.
try {{
Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"
Write-Host " HKLM\\SOFTWARE\\GE\\ShopDB restricted to SYSTEM and Administrators."
}}
catch {{
Write-Warning "could not restrict HKLM\\SOFTWARE\\GE\\ShopDB: $($_.Exception.Message)"
Write-Warning "the shared key on this PC stays readable by any logged-on user until that is fixed."
}}
# 1a. Trust the bundled server certificate, if one shipped. The agent's outbound
# TLS to a self-signed or internal-CA server fails closed until the machine
# trusts the cert. Importing into LocalMachine\\Root covers the SYSTEM service.