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:
129
tests/test_core/test_script_captureoutput.py
Normal file
129
tests/test_core/test_script_captureoutput.py
Normal 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
|
||||
Reference in New Issue
Block a user