A re-review showed the previous "single import choke point" claim was wrong: `plugins` is a normal importable package, so core request handlers that do `from plugins.<name>.models import ...` never passed through the loader and ran unverified - an attacker who dropped a file into plugins/<name>/ got arbitrary in-process code execution on an ordinary HTTP request (and a planted .pyc ran from cache). Gating load_plugin_class covered only plugin.py, one path of many. Fix: importguard.py installs a sys.meta_path finder (under enforcement) that intercepts EVERY plugins.<name>.* import, verifies the plugin's signed provenance once, then verifies each module file against it and execs the exact bytes it hashed - read once, compiled, exec'd, never a .pyc, never a re-opened file. This closes the submodule bypass and the planted-bytecode read, and the read-once exec closes the verify-vs-exec TOCTOU on the import path. The import system, not one method, is the real choke point. - init_app installs the guard when PLUGIN_REQUIRE_SIGNED, clears it otherwise. - load_plugin_class now verifies plugin.py from a single read and execs that buffer (finding #3 on that file); its submodule imports flow through the guard. - docs: stamp-bundled must cover every plugin dir present (a disabled plugin's module can be imported by core); recommend a read-only plugins/ owned by the deploy user as defense in depth (closes the residual migrate-time race an attacker with concurrent write could otherwise attempt). Earlier review's fixes #3 (migrate code paths) and #4 (shelf content binding) were confirmed sound and are unchanged. 7 import-guard tests (submodule verify, tamper, unsigned refused, planted .pyc ignored, real import through the guard, install/uninstall). 1061 pass, naming green.
5.7 KiB
Plugin signing and packaging (curator guide)
ADR-013 Phase 1. How a plugin becomes a signed, verifiable artifact and how a site trusts it. The signature proves an artifact is EXACTLY what a curator reviewed and signed - it does not prove the code is safe. Human review before signing is the actual safety control; the signature makes that review's verdict tamper-evident all the way to the point of execution.
Requires the cryptography package (already a dependency).
One-time: create the publisher key pair
flask plugin keygen --out ./keys --name curator
Writes keys/curator.key (PRIVATE) and keys/curator.pub (public).
- Keep the
.keyOFFLINE with the curator. It is the only thing that can sign a trusted artifact. Never put it on the plugin shelf or in the repo. - Distribute the
.pubwith each site's deployed config and pin it (below). - Rotation: generate a new pair, pin BOTH public keys on sites for an overlap
window (
verifyaccepts any trusted key), then retire the old one.
Per plugin: review, then pack
-
Review the plugin's source. This is the security gate - read what it does.
-
Validate and package in one step:
flask plugin pack printers --key ./keys/curator.key --publisher west-jeffersonpackrefuses to sign a directory that does not validate (manifest schema, name/dir match, core_version, dependencies on disk). On success it writesprinters-<version>.shopdbplugin- a zip of the plugin plus:PROVENANCE.json: name, version, publisher, created, and a sorted{file: sha256}map of every packaged file.PROVENANCE.sig: a detached ed25519 signature over the exactPROVENANCE.jsonbytes.
-
Publish the artifact to the shelf (a SharePoint-synced or copied folder). Transport is untrusted; the signature is what makes it safe.
Verify an artifact
flask plugin validate dist/printers-1.0.0.shopdbplugin --pubkey ./keys/curator.pub
Checks, fail-closed: signature against the trusted key(s), every file's hash,
no unexpected files, manifest schema, and that the plugin's core_version
admits this framework's contract version. Any changed byte in any file fails
the hash check; a signature from an untrusted key fails the signature check.
Pin trusted keys on a site
Set PLUGIN_TRUSTED_KEYS to one or more public-key PEM paths, separated by the
OS path separator (: on Linux, ; on Windows), in the site's environment:
PLUGIN_TRUSTED_KEYS=/etc/shopdb/keys/curator.pub:/etc/shopdb/keys/curator-next.pub
Keys are read only from this deployed config, never from the shelf - a folder an
attacker could write must not also carry the keys that authenticate it. With no
keys set, validate on an artifact fails closed (unverifiable).
Enforce signatures (Phase 2)
By default nothing is enforced - plugins load unsigned, as before. To require signatures on a site:
-
Stamp the plugins the image ships with, so verify-at-load applies to them too (run at image build with the site/build key):
flask plugin stamp-bundled --key ./keys/curator.keyThis writes
PROVENANCE.json+PROVENANCE.siginto each in-tree plugin. -
Pin the public key(s) and turn enforcement on (site config):
PLUGIN_TRUSTED_KEYS=/etc/shopdb/keys/curator.pub PLUGIN_REQUIRE_SIGNED=true
Now a plugin only loads or migrates when its tree matches a trusted signature.
Under enforcement a sys.meta_path guard verifies EVERY plugins.<name>.*
import (not just plugin.py) - including the from plugins.<name>.models import ... that core request handlers do - against the plugin's signed provenance, and
executes the exact bytes it hashed (never a .pyc). An unsigned, tampered, or
wrong-key plugin is refused, fail-closed. PLUGIN_DEV_TRUST_DIRS exempts named
directories, but ONLY under DEBUG/TESTING (the external-repo dev workflow);
production ignores it.
Because every plugins.* import is verified, stamp-bundled must cover EVERY
plugin directory present (its no-argument form does), not only the enabled ones
- core code can import a disabled plugin's module, and an unstamped one would be refused.
Defense in depth - set filesystem permissions so the app's runtime user CANNOT
write the plugins/ directory (owned by the deploy user). Import-time
verification closes the "attacker drops a file, a request imports it" path; a
strict read-only plugins/ also closes the narrow verify-vs-migrate race where
an attacker with concurrent write to plugins/ swaps a migration script between
the check and alembic re-reading it.
The shelf and adopt (Phase 2)
A shelf is a read-only folder of artifacts plus a signed index. The app reads
PLUGIN_SHELF_DIR; it never talks to SharePoint - a sync (or robocopy/USB)
populates that folder, and the signature makes the transport untrusted and
interchangeable.
Publish (curator, after packing artifacts into the shelf folder):
flask plugin shelf-build --dir /srv/shelf --key ./keys/curator.key --serial 3
The index carries a monotonic serial (a site refuses an index older than the
last it saw) and a revoked list (carried forward across builds). Bump
--serial on every publish.
On a site:
flask plugin shelf-list # browse (verifies index + serial)
flask plugin adopt printers # or printers==1.2.0
flask plugin audit # warn if an installed version is revoked
adopt verifies the shelf index and the artifact (signature + every file
hash), unpacks into a staging area, re-verifies, then atomically moves it into
place and installs + enables it with its dependency closure. It refuses a
downgrade unless --force-downgrade. Run flask plugin upgrade-all and restart
afterward so migrations apply and routes register.