Files
shopdb-flask/plugins/endpoints/api/adoption.py
cproudlock 5f27f81e88
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m18s
Clear the review blockers on the merged wilm work
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.
2026-08-30 17:17:13 -04:00

108 lines
4.1 KiB
Python

"""Endpoints adoption-tool packaging (SPIKE).
This module is deliberately self-contained so it can be cherry-picked onto
main as a single unit. It attaches ONE admin-gated route to the existing
endpoints blueprint and never edits routes.py.
The adoption tool is NOT the endpoint agent. It is a one-shot, agentless
enrollment client: given only the endpoints collector key and this server's
base URL, it creates a ShopDB PC (Computer) + Endpoint entry via the existing
POST /api/collector/endpoints adopt path, then exits. This route packages that
tool for download: a ZIP carrying the operator-supplied config, a README, and
(when a prebuilt binary is staged on the server) the shopdb-adopt.exe.
The collector key is supplied by the admin and is NOT stored or logged here.
The minted secret is unretrievable after generation, so the operator pastes it
into the generator; it only lives inside the downloaded config file.
"""
import io
import json
import os
import zipfile
from flask import request, current_app, send_file
from shopdb.api import error_response, ErrorCodes, require_permission
from .routes import endpoints_bp
# A staged, prebuilt shopdb-adopt.exe is embedded when present. The container
# has no .NET SDK, so this mirrors the endpoint agent installer model: build the
# exe out of band, drop it under instance/adoptiontool/, and it ships in the ZIP.
STAGED_EXE_RELPATH = os.path.join('adoptiontool', 'shopdb-adopt.exe')
_README = """ShopDB adoption tool (spike)
WHAT THIS IS
A one-shot, agentless enrollment tool. Run it once on a PC and it creates a
ShopDB PC and Endpoint entry using only the collector key in the config file
next to this exe. It collects identity, hardware, and installed-software (SBOM)
details, then exits. It installs nothing, runs no service, and sends no
ongoing telemetry. It is NOT the endpoint agent.
FILES
shopdb-adopt.exe The tool (present only if the server had a prebuilt copy).
adopt.config.json The base URL and collector key this tool will use.
USAGE
1. Keep shopdb-adopt.exe and adopt.config.json in the same folder.
2. Right-click shopdb-adopt.exe and choose Run as administrator. Elevation is
required so hardware and installed-software collection succeeds.
3. It reports the machine to ShopDB and exits 0 on success.
You can override config values with arguments:
shopdb-adopt.exe --baseurl https://shopdb.example --collectorkey KEY
SECURITY
adopt.config.json contains a live collector key. Treat it as a secret. The
key is not recoverable from the ShopDB server after it was generated, so keep
this download safe or regenerate the key to revoke it.
"""
def _build_readme():
return _README
@endpoints_bp.route('/adoptiontool/package', methods=['POST'])
@require_permission('endpoints.agentpackage')
def package_adoption_tool():
"""Bundle the adoption tool as a downloadable ZIP.
Body: {"baseurl": "...", "collectorkey": "..."}. Returns an in-memory ZIP
with adopt.config.json, README.txt, and the staged exe when available. The
collector key is embedded in the config only; it is never stored or logged.
"""
body = request.get_json(silent=True) or {}
baseurl = (body.get('baseurl') or '').strip()
collectorkey = (body.get('collectorkey') or '').strip()
if not baseurl or not collectorkey:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'baseurl and collectorkey are required',
http_code=400)
config = {
'baseurl': baseurl.rstrip('/'),
'collectorkey': collectorkey,
}
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('adopt.config.json',
json.dumps(config, indent=2) + '\n')
archive.writestr('README.txt', _build_readme())
staged = os.path.join(current_app.instance_path, STAGED_EXE_RELPATH)
if os.path.isfile(staged):
archive.write(staged, 'shopdb-adopt.exe')
buffer.seek(0)
return send_file(
buffer,
mimetype='application/zip',
as_attachment=True,
download_name='shopdb-adoption-tool.zip')