Merge remote-tracking branch 'origin/wj' into wilm
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user