Add log collection, script output capture, and timestamp fixes

Log Collector: a new collect_log action reads a plaintext log file from a
known path and returns its tail for viewing and download. Rides the existing
endpoints.updateapp grant rather than inventing one, since collecting a log is
the same trust level as pushing an application. Collected logs land in their
own table with per-asset retention of 20 instead of overloading the command
audit row, and the agent refuses UNC, traversal, wildcards, directories and
non-text content independently of the server check.

collect_log is POLICY_EXEMPT. Every existing Configuration Policy predates the
action and so cannot name it, and the intersection in ALLOWED_ACTIONS_FOR drops
anything unnamed - so every policied endpoint refused log collection outright,
including for admins, who saw a permission-shaped error for a grant they held.
Exemption from policy is not exemption from permission: the elevated grant
still applies, and a regression test asserts both halves so a future edit
cannot quietly loosen the grant while "fixing" the policy gate.

Script output capture: successful runs can return output, gated per script by
captureoutput. Failures always returned it; success never did, which left a
green badge as the only evidence a run produced.

Timestamps: Activity and Operator Reports rendered date-only because the shared
formatter's 'date' branch drops the clock time. Run Scripts additionally
labels its timestamp, since a bare time meant "completed" on a terminal row but
"queued" on an in-flight one - indistinguishable, and the wrong reading.

Agent 1.16.0. Minor: collect_log is a new action name, so an older agent asked
for it fails closed on the unknown-action default.
This commit is contained in:
benjamin.baker
2026-09-09 19:23:43 -04:00
parent 494b837bae
commit b49fb60c0c
19 changed files with 2207 additions and 21 deletions

View File

@@ -0,0 +1,129 @@
"""The per-script "return output on success" flag.
A failed run has always sent its output back, because a failure has to be
diagnosable from the UI. A successful run has not, and that is the behaviour
being made opt-in here rather than simply reversed: script output contains
whatever the body printed, and persisting that server-side for every routine
green run is exposure nobody asked for.
The load-bearing property is the default. Every script authored before this
field existed ran under "success stays silent", so an absent field must never
be read as consent.
"""
import pytest
from werkzeug.security import generate_password_hash
from shopdb.core.models import Permission
SCRIPT_GRANT = ('endpoints.runscript', 'Run staged scripts', 'endpoints')
@pytest.fixture
def headers(client, db):
from shopdb.core.models import User
Permission.seed_entries([SCRIPT_GRANT])
db.session.commit()
user = User(
username='captureauthor',
email='captureauthor@test.local',
passwordhash=generate_password_hash('testpass'),
)
user.directpermissions.append(
Permission.query.filter_by(name='endpoints.runscript').one())
db.session.add(user)
db.session.commit()
login = client.post('/api/auth/login',
json={'username': 'captureauthor', 'password': 'testpass'})
assert login.status_code == 200, login.get_json()
return {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"}
def _create(client, headers, name, **extra):
payload = {'scriptname': name}
payload.update(extra)
return client.post('/api/scripts', headers=headers, json=payload)
def test_a_script_without_the_flag_withholds_output(client, headers):
"""The load-bearing test. An omitted field is not consent to persist a
successful run's output server-side."""
created = _create(client, headers, 'no-capture')
assert created.status_code == 201, created.get_json()
assert created.get_json()['data']['captureoutput'] is False
def test_the_flag_can_be_turned_on(client, headers):
created = _create(client, headers, 'chatty-one', captureoutput=True)
assert created.status_code == 201, created.get_json()
assert created.get_json()['data']['captureoutput'] is True
@pytest.mark.parametrize('sent,expected', [
('true', True),
('false', False),
(True, True),
(False, False),
])
def test_form_style_values_are_accepted(client, headers, sent, expected):
"""The field arrives from a checkbox, which may send a string."""
created = _create(client, headers, f'form-{sent}-{expected}',
captureoutput=sent)
assert created.status_code == 201, created.get_json()
assert created.get_json()['data']['captureoutput'] is expected
def test_a_nonsense_value_is_rejected(client, headers):
"""Rejected rather than coerced: silently reading garbage as False would
hide a broken client, and as True would leak output nobody agreed to."""
created = _create(client, headers, 'bad-capture', captureoutput='maybe')
assert created.status_code == 400
assert 'captureoutput' in created.get_json()['data']['error']['message']
def test_the_flag_can_be_changed_on_an_existing_script(client, headers):
scriptid = _create(client, headers, 'toggle-me',
captureoutput=True).get_json()['data']['scriptid']
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
json={'captureoutput': False})
assert updated.status_code == 200, updated.get_json()
assert updated.get_json()['data']['captureoutput'] is False
def test_an_unrelated_update_leaves_the_flag_alone(client, headers):
"""Editing a description must not quietly revert an author's decision."""
scriptid = _create(client, headers, 'keep-my-flag',
captureoutput=True).get_json()['data']['scriptid']
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
json={'scriptdescription': 'now documented'})
assert updated.get_json()['data']['captureoutput'] is True
def test_the_delivery_descriptor_carries_the_flag(app, client, db, headers):
"""The agent cannot honour a decision it is never told about."""
from plugins.endpoints.api.routes import _resolve_run_script
with app.app_context():
from shopdb.core.models import Script
script = Script(scriptname='delivered', filename='script-1.ps1',
sha256='a' * 64, runcontext='system',
captureoutput=True)
db.session.add(script)
db.session.commit()
descriptor, err = _resolve_run_script(f'script:{script.scriptid}')
assert err is None
assert descriptor['captureoutput'] is True