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.
108 lines
4.1 KiB
Python
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')
|