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,48 @@
"""Add scripts captureoutput
Backs the per-script "may a successful run send its output back" field.
The agent already captures a script's full output and always writes it to the
endpoint log. What this controls is narrower: whether that output also crosses
to the server and is persisted in the command's resultjson, where the Run
Scripts history can show it.
Failures have always sent output and still do - a failed run has to be
diagnosable without walking to the machine. Success is the case in question,
and it is opt-in because output carries whatever the body happened to print:
hostnames, share paths, an account name, a token an author echoed while
debugging. Accepting that exposure for every routine green run is a poor
trade, so the author decides per script, after reading the body.
BOOLEAN NOT NULL defaulting FALSE, so every script row written before this
migration keeps exactly today's behaviour: metadata on success, output only on
failure. Same rule as errormode - never retroactive, because a library-wide
flip would start persisting output from bodies nobody re-read with this
question in mind.
Revision ID: 7d50_script_captureoutput
Revises: 7d49_script_errormode
Create Date: 2026-09-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d50_script_captureoutput'
down_revision = '7d49_script_errormode'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('scripts', sa.Column(
'captureoutput', sa.Boolean(), nullable=False,
server_default=sa.false(),
comment='When true, a successful run also returns its output to the '
'server; failures always do. Off by default because script '
'output can contain whatever the body printed.'))
def downgrade():
op.drop_column('scripts', 'captureoutput')

View File

@@ -0,0 +1,92 @@
"""Add endpointlogcollections
Backs the manual Log Collector tab: one row per plaintext log file an operator
collected from an endpoint by path.
Collected content gets its own table rather than living in
endpointcommands.resultjson for three reasons. It is bulky, and resultjson is
read by every command-history list that only wants a status. It needs its own
retention rule, which a command row cannot carry without deleting the audit
record of the command itself. And the download route has to address a single
collection by id and serve it as a file, which is awkward against a JSON column.
Retention is PER ASSET (20, enforced on insert in the result route), not global.
The history renders on a single machine's detail page, so a global cap would let
one busy endpoint quietly evict the history of another - the panel would be
unreliable exactly where it is read.
endpointcommandid is SET NULL rather than CASCADE: commands are reaped and
expired over time, and a collected log an operator is still reading must not
vanish because its originating command row was cleaned up. assetid stays CASCADE
because a log has no meaning once the machine is gone.
filesizebytes is BIGINT while contentbytes is INTEGER on purpose - the file on
disk may be gigabytes, while what we store is a bounded tail.
Revision ID: 7d51_endpoint_logcollections
Revises: 7d50_script_captureoutput
Create Date: 2026-09-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d51_endpoint_logcollections'
down_revision = '7d50_script_captureoutput'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'endpointlogcollections',
sa.Column('endpointlogcollectionid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('endpointcommandid', sa.Integer(), nullable=True),
sa.Column('sourcepath', sa.String(length=512), nullable=False,
comment='Path as the AGENT resolved it, which may differ '
'from what the operator typed once environment '
'variables are expanded endpoint-side.'),
sa.Column('content', sa.Text(length=16777215), nullable=True),
sa.Column('filesizebytes', sa.BigInteger(), nullable=True,
comment='Size of the file on disk. Differs from contentbytes '
'when the stored copy is a truncated tail.'),
sa.Column('contentbytes', sa.Integer(), nullable=True),
sa.Column('linecount', sa.Integer(), nullable=True),
sa.Column('istruncated', sa.Boolean(), nullable=False,
server_default=sa.false(),
comment='True when content is only the tail of a larger file.'),
sa.Column('filemodifiedat', sa.DateTime(), nullable=True),
sa.Column('collectedbyuserid', sa.Integer(), nullable=True),
sa.Column('collectedbyusername', sa.String(length=100), nullable=True,
comment='Username snapshot; the id alone becomes unreadable '
'if the user is later removed.'),
sa.Column('collectedat', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.ForeignKeyConstraint(['endpointcommandid'],
['endpointcommands.endpointcommandid'],
ondelete='SET NULL'),
sa.ForeignKeyConstraint(['collectedbyuserid'], ['users.userid'],
ondelete='SET NULL'),
sa.PrimaryKeyConstraint('endpointlogcollectionid'),
)
op.create_index('ix_endpointlogcollections_assetid',
'endpointlogcollections', ['assetid'])
op.create_index('ix_endpointlogcollections_collectedat',
'endpointlogcollections', ['collectedat'])
# Serves both hot paths: listing an asset's history newest-first, and
# finding that asset's oldest rows to prune on insert.
op.create_index('idx_endpointlogcollections_assetcollected',
'endpointlogcollections', ['assetid', 'collectedat'])
def downgrade():
op.drop_index('idx_endpointlogcollections_assetcollected',
table_name='endpointlogcollections')
op.drop_index('ix_endpointlogcollections_collectedat',
table_name='endpointlogcollections')
op.drop_index('ix_endpointlogcollections_assetid',
table_name='endpointlogcollections')
op.drop_table('endpointlogcollections')