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:
48
migrations/versions/7d50_script_captureoutput.py
Normal file
48
migrations/versions/7d50_script_captureoutput.py
Normal 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')
|
||||
92
migrations/versions/7d51_endpoint_logcollections.py
Normal file
92
migrations/versions/7d51_endpoint_logcollections.py
Normal 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')
|
||||
Reference in New Issue
Block a user