A multi-agent review of the merged branch produced 29 findings that survived
adversarial verification. This clears the blockers and the four worth fixing
before it reaches main. The design held up well: path-traversal defence on all
four serving routes is clean, the agent's SHA-256 check fails closed, enqueue
authority is proportionate and audited, and no site literals or credential leaks
in the new C#.
THE TESTS NEVER RAN
pytest.ini sets testpaths = tests, so plugins/endpoints/tests/ was collected ZERO
times. Fifty-five lines of delivery assertions were dead code, and run directly
five of them failed. plugins/printedparts/tests/ had been invisible for longer.
That is the worst shape a gap can take: the suite is green, the coverage is
imaginary, and the collector auth on a download route could be deleted without
anything going red.
Both files moved under tests/. tests/test_no_hidden_tests.py now fails if a test
file appears anywhere pytest will not look; it caught a staged copy inside the
installer bundle on its first run, and was verified by planting a violation.
The five failures were stale expectations, and they split by cause. His: the
applications panel made six panels where his test said five, and 'applications'
belongs in KNOWN_PAYLOAD_KEYS now that it is persisted - his own test contradicted
his own feature. Ours: we renamed the events card to activity, moved the
fleet-wide cards to endpoints.fleet, and made the list route a bare route('')
which Flask rejects unless the blueprint is mounted under a prefix.
FOUR FIXES
The snapshot table's key was a 32-bit autoincrement on a table rewritten
wholesale every check-in, so it climbs at (endpoints x apps) per report and
exhausts on MySQL. Widened while the table is unreleased. Widening it broke every
insert on SQLite, which only autoincrements a column typed exactly INTEGER
PRIMARY KEY, so it carries a dialect variant - and nothing caught either fault,
because no test inserted into that table at all. One does now.
Ingest truncated displayname but not displayversion or publisher. On strict MySQL
an over-long registry value fails the whole check-in transaction, so one machine
with a malformed Uninstall entry goes silent entirely. SQLite does not enforce
lengths, so the suite would never have shown it.
update_app's version: branch pinned AppVersion.sha256, but that route resolves the
version to its APPLICATION and serves application.installpath - so the digest
described an artifact the route never sends, and where it was null the agent had
nothing to verify and failed open. It now pins the file actually served.
The same branch ended the delivery URL at the id, so an MSI arrived with no
extension and could not be routed through msiexec. It carries the filename now.
Agent packaging moved off require_role('admin') to a new endpoints.agentpackage
permission. The generated ZIP carries a live collector key, so this is a heavier
grant than deploying to one endpoint - and a role cannot be given to one person
for one job the way a permission can.
Full suite 2231 passed (2198 before, plus the 22 that never ran and 11 new),
frontend 314.
95 lines
3.8 KiB
Python
95 lines
3.8 KiB
Python
"""endpoint installed-software (SBOM) snapshot table.
|
|
|
|
Adds endpointapplications: one row per installed application discovered on an
|
|
endpoint, reported by the agent as {displayname, displayversion, publisher}.
|
|
Stored as a per-asset snapshot (replaced on each report), the same way the
|
|
services/ports inventories are kept as the newest list rather than history.
|
|
|
|
appid is groundwork for a future link to the core applications catalog (the
|
|
"Managed Applications" concept): a nullable FK with ON DELETE SET NULL so
|
|
retiring a managed Application does not delete discovered rows. No
|
|
discovery-to-catalog matcher exists yet; the column is laid down now so the
|
|
association can be added without a second migration.
|
|
|
|
Idempotent: creation checks the live schema first so a db.create_all() site
|
|
(which already built the table) re-runs clean on strict MySQL 8.
|
|
"""
|
|
from alembic import op
|
|
|
|
import sqlalchemy as sa
|
|
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision = 'endpoints0004apps'
|
|
down_revision = 'endpoints0003cmdtarget'
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def _has_table(table_name):
|
|
from sqlalchemy import inspect
|
|
|
|
return inspect(op.get_bind()).has_table(table_name)
|
|
|
|
|
|
def upgrade():
|
|
if _has_table('endpointapplications'):
|
|
return
|
|
|
|
op.create_table(
|
|
'endpointapplications',
|
|
# BigInteger, not Integer. This is a SNAPSHOT table: every check-in
|
|
# deletes the endpoint's rows and reinserts them, so the surrogate key
|
|
# burns through values at (endpoints x installed apps) per report and
|
|
# never reuses them. A 32-bit key exhausts on MySQL - a few hundred
|
|
# endpoints reporting a few hundred apps every 15 minutes reaches 2.1
|
|
# billion inside a couple of years, and the failure is an insert that
|
|
# stops working forever. SQLite has no such ceiling, so no test would
|
|
# have caught it. Changed while the table is still unreleased.
|
|
sa.Column('endpointapplicationid',
|
|
sa.BigInteger().with_variant(sa.Integer, 'sqlite'),
|
|
nullable=False),
|
|
sa.Column('assetid', sa.Integer(), nullable=False),
|
|
sa.Column('displayname', sa.String(length=255), nullable=False),
|
|
sa.Column('displayversion', sa.String(length=128), nullable=True),
|
|
sa.Column('publisher', sa.String(length=255), nullable=True),
|
|
sa.Column('appid', sa.Integer(), nullable=True),
|
|
sa.Column('recordedat', sa.DateTime(), nullable=False),
|
|
sa.PrimaryKeyConstraint('endpointapplicationid'),
|
|
sa.ForeignKeyConstraint(
|
|
['assetid'], ['assets.assetid'],
|
|
name='fk_endpointapplications_assetid',
|
|
ondelete='CASCADE'),
|
|
sa.ForeignKeyConstraint(
|
|
['appid'], ['applications.appid'],
|
|
name='fk_endpointapplications_appid',
|
|
ondelete='SET NULL'),
|
|
)
|
|
op.create_index(
|
|
'idx_endpointapplications_assetid',
|
|
'endpointapplications', ['assetid'])
|
|
op.create_index(
|
|
'idx_endpointapplications_appid',
|
|
'endpointapplications', ['appid'])
|
|
op.create_index(
|
|
'idx_endpointapplications_displayname',
|
|
'endpointapplications', ['displayname'])
|
|
op.create_index(
|
|
'idx_endpointapplications_assetname',
|
|
'endpointapplications', ['assetid', 'displayname'])
|
|
|
|
|
|
def downgrade():
|
|
if not _has_table('endpointapplications'):
|
|
return
|
|
|
|
op.drop_index('idx_endpointapplications_assetname',
|
|
table_name='endpointapplications')
|
|
op.drop_index('idx_endpointapplications_displayname',
|
|
table_name='endpointapplications')
|
|
op.drop_index('idx_endpointapplications_appid',
|
|
table_name='endpointapplications')
|
|
op.drop_index('idx_endpointapplications_assetid',
|
|
table_name='endpointapplications')
|
|
op.drop_table('endpointapplications')
|