Promote the 2026-08-30 review security fixes and supplies ordering to main
This commit is contained in:
@@ -7,6 +7,7 @@ These routes are the human-facing read surface, gated by user JWT + RBAC.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
@@ -941,6 +942,43 @@ _CLAIM_HOLD_SECONDS = 25
|
||||
_CLAIM_POLL_INTERVAL = 1.0
|
||||
|
||||
|
||||
def _pin_holds(command, descriptor):
|
||||
"""Does the descriptor still carry the digest this command was queued for?
|
||||
|
||||
True when nothing was pinned (actions that deliver no content), or when the
|
||||
descriptor is missing entirely - a vanished script or version already sends
|
||||
the command through with no descriptor so the agent fails closed, and that
|
||||
path is not a content swap.
|
||||
"""
|
||||
if not command.pinnedsha256:
|
||||
return True
|
||||
if descriptor is None:
|
||||
return True
|
||||
return hmac.compare_digest(
|
||||
str(descriptor.get('sha256') or ''), command.pinnedsha256)
|
||||
|
||||
|
||||
def _fail_on_pin_drift(command, action):
|
||||
"""Refuse a claim whose content changed after the operator approved it."""
|
||||
command.status = EndpointCommand.STATUS_FAILED
|
||||
command.resultmessage = (
|
||||
'Refused: the {} content changed after this command was queued'
|
||||
.format(action))
|
||||
command.completedat = _naive_utcnow()
|
||||
db.session.commit()
|
||||
current_app.logger.warning(
|
||||
'Endpoint command %s (%s, asset %s) refused: pinned sha256 %s no '
|
||||
'longer matches the resolved descriptor',
|
||||
command.endpointcommandid, action, command.assetid,
|
||||
command.pinnedsha256)
|
||||
AuditLog.log('refused', 'EndpointCommand',
|
||||
entityid=command.endpointcommandid,
|
||||
entityname=action,
|
||||
details='content digest changed after enqueue')
|
||||
db.session.commit()
|
||||
return ('', 204)
|
||||
|
||||
|
||||
def _reap_stale_commands(asset_id):
|
||||
"""Expire in-flight commands an agent will never finish for this endpoint.
|
||||
|
||||
@@ -1234,18 +1272,25 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
# update_app pins a specific application version; verify it resolves to a
|
||||
# real installer now so a broken push is rejected at enqueue rather than
|
||||
# failing opaquely on the endpoint minutes later.
|
||||
# Digest of the content this push was APPROVED against. Kept on the row so
|
||||
# the claim path can refuse a descriptor that has changed since; see
|
||||
# EndpointCommand.pinnedsha256.
|
||||
pinnedsha256 = None
|
||||
|
||||
if action == 'update_app':
|
||||
_descriptor, resolve_error = _resolve_update_app(target)
|
||||
descriptor, resolve_error = _resolve_update_app(target)
|
||||
if resolve_error is not None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, resolve_error)
|
||||
pinnedsha256 = (descriptor or {}).get('sha256')
|
||||
|
||||
# run_script pins a ShopDB-hosted script; verify it resolves to a real
|
||||
# uploaded body now so a broken push is rejected at enqueue rather than
|
||||
# failing opaquely on the endpoint later.
|
||||
if action == 'run_script':
|
||||
_script, script_error = _resolve_run_script(target)
|
||||
script, script_error = _resolve_run_script(target)
|
||||
if script_error is not None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
|
||||
pinnedsha256 = (script or {}).get('sha256')
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if state is None:
|
||||
@@ -1277,6 +1322,7 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
targetkind=targetkind if target else None,
|
||||
status=EndpointCommand.STATUS_PENDING,
|
||||
requestedby=getattr(current_user, 'userid', None),
|
||||
pinnedsha256=pinnedsha256,
|
||||
)
|
||||
db.session.add(command)
|
||||
|
||||
@@ -1337,6 +1383,8 @@ def claim_next_command():
|
||||
# fail closed rather than 500 the poll.
|
||||
if command.action == 'update_app':
|
||||
descriptor, _resolve_error = _resolve_update_app(command.target)
|
||||
if not _pin_holds(command, descriptor):
|
||||
return _fail_on_pin_drift(command, 'update_app')
|
||||
if descriptor is not None:
|
||||
claim['app'] = descriptor
|
||||
# run_script rides a resolved script descriptor: the agent gets a
|
||||
@@ -1347,6 +1395,8 @@ def claim_next_command():
|
||||
# agent fail closed rather than 500 the poll.
|
||||
if command.action == 'run_script':
|
||||
script_descriptor, _script_error = _resolve_run_script(command.target)
|
||||
if not _pin_holds(command, script_descriptor):
|
||||
return _fail_on_pin_drift(command, 'run_script')
|
||||
if script_descriptor is not None:
|
||||
claim['script'] = script_descriptor
|
||||
return success_response(claim)
|
||||
@@ -1376,6 +1426,33 @@ def report_command_result(command_id: int):
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No command {command_id}', http_code=404)
|
||||
|
||||
# An agent may only finalize ITS OWN command. The collector key is shared
|
||||
# fleet-wide, so without this any agent could post a result for any command
|
||||
# id - marking an install succeeded on a machine that never ran it, which
|
||||
# writes a false compliance record. Binding to the claimed hostname is a
|
||||
# floor, not a fix: the credential still does not prove which endpoint is
|
||||
# calling, and only a per-endpoint credential closes that.
|
||||
# Optional because the shipped agent does not send it yet; enforced when it
|
||||
# does, so an updated fleet gets the check without a flag day.
|
||||
hostname = (request.args.get('hostname') or '').strip()
|
||||
if hostname:
|
||||
caller_asset_id = _resolve_asset_for_hostname(hostname)
|
||||
if caller_asset_id != command.assetid:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'This command belongs to another endpoint', http_code=403)
|
||||
|
||||
# Only a claimed command can be finalized. Needs no agent change, and it
|
||||
# closes two things the hostname check cannot: reporting on a command that
|
||||
# was never claimed, and overwriting a terminal result - which would let a
|
||||
# recorded failure be replaced by a success.
|
||||
if command.status != EndpointCommand.STATUS_RUNNING:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Command {} is {}, not running'.format(
|
||||
command_id, command.status),
|
||||
http_code=409)
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
succeeded = bool(payload.get('succeeded'))
|
||||
command.status = (EndpointCommand.STATUS_SUCCEEDED if succeeded
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Pin the approved content digest on an endpoint command.
|
||||
|
||||
Delivery descriptors (run_script, update_app) are re-resolved when the agent
|
||||
claims a command, not when an operator queues it. Between those two moments the
|
||||
underlying script body or installer package can be replaced by anyone who can
|
||||
edit the applications catalog, and the descriptor's SHA-256 would then describe
|
||||
the NEW bytes: the agent verifies correctly and runs something the operator
|
||||
never approved.
|
||||
|
||||
pinnedsha256 records the digest as it stood at enqueue. The claim path compares
|
||||
and refuses the command if it has moved, so a swap fails the push instead of
|
||||
shipping it.
|
||||
|
||||
Null for actions that deliver no content, and null on rows queued before this
|
||||
migration - those are treated as unpinned and claim as before.
|
||||
|
||||
Idempotent: checks the live schema first so a db.create_all() site re-runs clean
|
||||
on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0005cmdpin'
|
||||
down_revision = 'endpoints0004apps'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _has_column('endpointcommands', 'pinnedsha256'):
|
||||
op.add_column('endpointcommands',
|
||||
sa.Column('pinnedsha256', sa.String(length=64),
|
||||
nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_column('endpointcommands', 'pinnedsha256'):
|
||||
op.drop_column('endpointcommands', 'pinnedsha256')
|
||||
@@ -172,6 +172,19 @@ class EndpointCommand(db.Model):
|
||||
# Who queued it (userid) for audit; the agent never sees this.
|
||||
requestedby = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# The content digest as it stood WHEN AN OPERATOR APPROVED THIS PUSH.
|
||||
#
|
||||
# Delivery descriptors are re-resolved at claim time, minutes or hours
|
||||
# later, so without this the bytes an operator approved and the bytes the
|
||||
# agent runs are two different lookups against mutable content. Whoever can
|
||||
# replace a script body or an installer package could swap it in the gap
|
||||
# and the digest would faithfully describe the new bytes.
|
||||
#
|
||||
# Set at enqueue for run_script and update_app; null for actions that
|
||||
# deliver no content. The claim path refuses a command whose descriptor no
|
||||
# longer matches, so a swap fails the command instead of shipping.
|
||||
pinnedsha256 = db.Column(db.String(64), nullable=True)
|
||||
|
||||
# Agent-reported outcome. resultjson holds structured detail (for example
|
||||
# the observed MTConnect running state); resultmessage is a short summary.
|
||||
resultmessage = db.Column(db.Text, nullable=True)
|
||||
|
||||
@@ -145,10 +145,11 @@ class EndpointsPlugin(BasePlugin):
|
||||
# including every logged-in username on the floor, because the
|
||||
# panels and the list were gated on the same string.
|
||||
#
|
||||
# It is deliberately NOT named '.view'. guest_grantable_permissions
|
||||
# opens any '.view' or '.read' permission to the guest role, so this
|
||||
# name is what keeps the fleet roster off the public surface while
|
||||
# per-asset telemetry can still be granted.
|
||||
# The name avoids '.view' for readability, but the NAME no longer
|
||||
# decides anything: guest_grantable_permissions is a declared list
|
||||
# now, and this plugin blesses endpoints.view alone below. Adding
|
||||
# endpoints.fleet there would be what opens the roster, and the
|
||||
# docstring on that hook says why not to.
|
||||
('endpoints.fleet',
|
||||
'View the fleet-wide endpoint list and dashboards', 'endpoints'),
|
||||
# Issue an allowlisted command to an endpoint agent (for example the
|
||||
@@ -187,6 +188,21 @@ class EndpointsPlugin(BasePlugin):
|
||||
'Submit endpoint agent telemetry (fleet reporting)', 'collector'),
|
||||
]
|
||||
|
||||
def get_guest_grantable_permissions(self) -> List[str]:
|
||||
"""Declared because a site chose it: live status on a PC page.
|
||||
|
||||
endpoints.view only. NOT endpoints.fleet - that is the roster, and the
|
||||
whole reason the two were split.
|
||||
|
||||
KNOWN GAP, tracked separately: the per-asset panels this opens are
|
||||
keyed on a small integer asset id and the status panel emits the
|
||||
logged-in username, so walking ids reconstructs most of the roster the
|
||||
split exists to protect. Bounding or rate-limiting the per-asset panels
|
||||
is the fix. Until then a site enabling guest access should know the
|
||||
split is a speed bump here, not a wall.
|
||||
"""
|
||||
return ['endpoints.view']
|
||||
|
||||
def get_settings_defaults(self) -> List[Dict]:
|
||||
"""Settings owned by the endpoints agent-key kill switch.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user