feat: install plugins from approved source commits
CI / php-store (push) Waiting to run
CI / python-components (push) Waiting to run

This commit is contained in:
2026-08-24 21:37:17 +02:00
parent f36d6be511
commit 26aea40e6a
27 changed files with 414 additions and 47 deletions
+4 -3
View File
@@ -6,7 +6,7 @@ decision is revalidated against the configured JSON API immediately before a lif
The daemon is **dry-run by default**. It serializes operations, persists idempotency and state in
SQLite, accepts one bounded JSON line per Unix-stream connection, verifies Linux peer credentials,
downloads only an approved immutable wheel, and invokes subprocesses only as fixed argument arrays
downloads only an approved immutable wheel or commit-bound source archive, and invokes subprocesses only as fixed argument arrays
with `shell=False`.
## Install and one-time operator setup
@@ -27,7 +27,7 @@ active; this switch only permits private/reserved DNS results.
The agent owns only these two configured files:
- `paths.include_path`, which contains only `STORE_PLUGINS = [...]`;
- `paths.requirements_path`, which contains the locked direct wheel references.
- `paths.requirements_path`, which contains locked wheel references. Locally built wheels are cached below `/opt/netbox/.netbox-store-wheels`.
In the operator-owned NetBox `configuration.py`, add once, after the normal `PLUGINS` declaration:
@@ -112,7 +112,7 @@ value as an opaque Store marker, and the agent compares it exactly with the fres
Artifact `sha256`, in contrast, is always a lowercase 64-character SHA-256.
The plugin and release must be approved, the release immutable and compatible with the configured
NetBox version. The artifact must be a valid wheel whose filename distribution and version match
NetBox version. A wheel's filename distribution and version must match
the catalog, and its byte count, digest, host, scheme and DNS addresses are checked while streaming.
Redirects, source distributions, private/reserved DNS targets (unless explicitly enabled for a test
environment), and catalog additions outside the v1 schema fail closed.
@@ -151,6 +151,7 @@ netbox-store-agent status 20f4274f-d4e5-42bf-9164-967b1a774481
- `install` and `update` re-fetch plugin and release, exactly match the approval token, download and
validate the wheel, then use `pip --no-index --no-deps --only-binary=:all: --require-hashes`.
- Source archives must be tied to an exact commit. The agent rejects unsafe archive members and requires configured `unshare`/`setpriv` executables plus a dedicated build UID/GID. It builds without network access and with dropped privileges via `pip wheel --no-index --no-deps --no-build-isolation`, validates the result, and caches only that wheel for future NetBox upgrades. Reviewing source code remains a privileged trust decision because Python build backends execute code.
- New installs remain disabled. Updating an enabled plugin runs NetBox `migrate`, `collectstatic`,
and restarts every configured service (`netbox` and `netbox-rq` in the example).
- `enable` revalidates its installed release, writes the include, migrates, collects static files,
+7 -2
View File
@@ -17,7 +17,7 @@ connection_timeout_seconds = 5
worker_threads = 1
[store]
base_url = "https://store.example.invalid"
base_url = "https://netbox.mrblake.cc"
plugin_endpoint_template = "/api/v1/plugins/{plugin_slug}/"
release_endpoint_template = "/api/v1/plugins/{plugin_slug}/releases/{version}/"
timeout_seconds = 10
@@ -26,7 +26,7 @@ max_artifact_bytes = 268435456
allow_private_addresses = false
allow_http_for_testing = false
# Add an artifact origin only when releases intentionally use that origin.
allowed_hosts = ["store.example.invalid"]
allowed_hosts = ["netbox.mrblake.cc", "git.mrblake.cc", "github.com", "codeload.github.com"]
# bearer_token_file = "/etc/netbox-store-agent/store.token"
# ca_file = "/etc/ssl/certs/internal-store-ca.pem"
@@ -42,6 +42,11 @@ manage_path = "/opt/netbox/netbox/manage.py"
systemctl_path = "/usr/bin/systemctl"
services = ["netbox", "netbox-rq"]
command_timeout_seconds = 900
unshare_path = "/usr/bin/unshare"
setpriv_path = "/usr/bin/setpriv"
# Dedicated unprivileged account used only for reviewed source builds.
source_build_uid = 65534
source_build_gid = 65534
[policy]
netbox_version = "4.6.8"
+73 -7
View File
@@ -8,6 +8,7 @@ import os
import socket
import ssl
import stat
import tarfile
import zipfile
from dataclasses import dataclass
from pathlib import Path
@@ -264,6 +265,8 @@ class ReleasePlan:
artifact_sha256: str
artifact_size: int
approved_payload_sha256: str
artifact_kind: str = "wheel"
artifact_filename: str = ""
def requirement(self) -> dict[str, Any]:
return {
@@ -275,6 +278,14 @@ class ReleasePlan:
"size": self.artifact_size,
}
def artifact_lock(self) -> dict[str, Any]:
return {
**self.requirement(),
"artifact_kind": self.artifact_kind,
"artifact_filename": self.artifact_filename or self.filename,
"approved_payload_sha256": self.approved_payload_sha256,
}
def _object(value: Any, context: str) -> dict[str, Any]:
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
@@ -321,6 +332,8 @@ class StoreClient:
"download_url",
"sha256",
"artifact_size",
"artifact_kind",
"artifact_filename",
"commit_sha",
"min_netbox_version",
"max_netbox_version",
@@ -432,12 +445,26 @@ class StoreClient:
raise CatalogError("release.commit_sha is invalid")
if data["published_at"] is not None:
_bounded_string(data["published_at"], "release.published_at", 100)
parsed_url = urlsplit(download_url)
filename = Path(parsed_url.path).name
if not filename or not filename.endswith(".whl") or len(filename) > 255:
raise CatalogError("approved artifact must be a wheel with a safe filename")
artifact_kind = _bounded_string(data["artifact_kind"], "release.artifact_kind", 32)
if artifact_kind not in {"wheel", "source_archive"}:
raise CatalogError("release.artifact_kind is unsupported")
filename = _bounded_string(data["artifact_filename"], "release.artifact_filename", 255)
if Path(filename).name != filename:
raise CatalogError("release.artifact_filename is unsafe")
if artifact_kind == "wheel" and not filename.endswith(".whl"):
raise CatalogError("wheel artifact filename is invalid")
if artifact_kind == "source_archive":
if (
not filename.endswith(".tar.gz")
or len(commit) != 40
or commit not in urlsplit(download_url).path
):
raise CatalogError("source archive must be bound to an exact commit")
# URL host/scheme/DNS are revalidated by the transport at download time.
return ReleasePlan(plugin, version_text, download_url, filename, sha256, size, token)
return ReleasePlan(
plugin, version_text, download_url, filename, sha256, size, token,
artifact_kind, filename,
)
def get_release(self, slug: str, version: str) -> ReleasePlan:
plugin = self.get_plugin(slug)
@@ -464,8 +491,15 @@ class StoreClient:
expected_size=plan.artifact_size,
expected_sha256=plan.artifact_sha256,
)
if plan.artifact_kind == "source_archive":
self._validate_source_archive(destination)
return destination
self.validate_wheel(plan, destination)
return destination
def validate_wheel(self, plan: ReleasePlan, destination: Path) -> None:
try:
distribution, wheel_version, _build, _tags = parse_wheel_filename(plan.filename)
distribution, wheel_version, _build, _tags = parse_wheel_filename(destination.name)
except (InvalidVersion, ValueError) as exc:
raise CatalogError("artifact filename is not a valid wheel filename") from exc
if canonicalize_name(distribution) != canonicalize_name(plan.plugin.package_name):
@@ -494,4 +528,36 @@ class StoreClient:
raise CatalogError("wheel archive failed its integrity check")
except (OSError, zipfile.BadZipFile) as exc:
raise CatalogError("artifact is not a valid wheel archive") from exc
return destination
def _validate_source_archive(self, archive_path: Path) -> None:
try:
with tarfile.open(archive_path, "r:gz") as archive:
members = archive.getmembers()
if not members or len(members) > 10_000:
raise CatalogError("source archive has an invalid member count")
expanded = 0
roots: set[str] = set()
has_build_file = False
for member in members:
parts = Path(member.name).parts
if (
not parts
or member.name.startswith(("/", "\\"))
or ".." in parts
or "\x00" in member.name
):
raise CatalogError("source archive contains an unsafe member")
if member.issym() or member.islnk() or member.isdev() or member.isfifo():
raise CatalogError("source archive contains a forbidden member type")
roots.add(parts[0])
expanded += member.size
if expanded > min(
self.config.store.max_artifact_bytes * 20, 2 * 1024**3
):
raise CatalogError("source archive expands beyond the safety limit")
if len(parts) == 2 and parts[1] in {"pyproject.toml", "setup.py"}:
has_build_file = True
if len(roots) != 1 or not has_build_file:
raise CatalogError("source archive must have one root and a Python build file")
except (OSError, tarfile.TarError) as exc:
raise CatalogError("artifact is not a valid source archive") from exc
+13 -1
View File
@@ -71,6 +71,10 @@ class CommandSettings:
systemctl_path: Path
services: tuple[str, ...]
command_timeout_seconds: int
unshare_path: Path | None = None
setpriv_path: Path | None = None
source_build_uid: int = 65534
source_build_gid: int = 65534
@dataclass(frozen=True)
@@ -240,7 +244,11 @@ def load_config(path: str | Path, *, allow_insecure_owner: bool = False) -> Conf
commands_data = _table(
root,
"commands",
{"python_path", "manage_path", "systemctl_path", "services", "command_timeout_seconds"},
{
"python_path", "manage_path", "systemctl_path", "services",
"command_timeout_seconds", "unshare_path", "setpriv_path",
"source_build_uid", "source_build_gid",
},
)
policy_data = _table(
root,
@@ -342,6 +350,10 @@ def load_config(path: str | Path, *, allow_insecure_owner: bool = False) -> Conf
systemctl_path=require_absolute_path(_string(commands_data, "systemctl_path"), "systemctl_path"),
services=services,
command_timeout_seconds=_int(commands_data, "command_timeout_seconds", 900, 1, 7200),
unshare_path=_optional_path(commands_data, "unshare_path"),
setpriv_path=_optional_path(commands_data, "setpriv_path"),
source_build_uid=_int(commands_data, "source_build_uid", 65534, 1, 2**31 - 1),
source_build_gid=_int(commands_data, "source_build_gid", 65534, 1, 2**31 - 1),
)
versions: dict[str, str] = {}
+89 -11
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
import hashlib
import os
import secrets
import shutil
import tarfile
import tempfile
from pathlib import Path
from typing import Callable
@@ -56,7 +60,11 @@ class OperationProcessor:
@staticmethod
def _same_release(existing: ManagedPlugin, plan: ReleasePlan) -> None:
if existing.version != plan.version or existing.requirements != (plan.requirement(),):
expected = plan.artifact_lock() if existing.artifact_lock else plan.requirement()
current_lock = existing.artifact_lock or (
existing.requirements[0] if existing.requirements else {}
)
if existing.version != plan.version or current_lock != expected:
raise PolicyError(
"managed artifact lock differs from the current immutable Store release"
)
@@ -105,6 +113,71 @@ class OperationProcessor:
],
)
def _build_source_wheel(
self, operation_id: str, directory: Path, plan: ReleasePlan, archive: Path
) -> Path:
if (
os.name != "posix"
or self.config.commands.unshare_path is None
or self.config.commands.setpriv_path is None
):
raise PolicyError("source builds require configured Linux unshare and setpriv executables")
source_dir = directory / "source"
wheel_dir = directory / "wheel"
source_dir.mkdir(mode=0o700)
wheel_dir.mkdir(mode=0o700)
with tarfile.open(archive, "r:gz") as bundle:
bundle.extractall(source_dir, filter="data")
roots = list(source_dir.iterdir())
if len(roots) != 1 or not roots[0].is_dir():
raise PolicyError("source archive extraction produced an invalid root")
uid = self.config.commands.source_build_uid
gid = self.config.commands.source_build_gid
for path in [directory, source_dir, wheel_dir, *source_dir.rglob("*")]:
os.chown(path, uid, gid, follow_symlinks=False)
if path.is_dir():
os.chmod(path, 0o700)
self._run(
operation_id,
"source_build",
[
str(self.config.commands.unshare_path), "--net", "--",
str(self.config.commands.setpriv_path), f"--reuid={uid}", f"--regid={gid}",
"--clear-groups", "--no-new-privs", "--",
str(self.config.commands.python_path), "-m", "pip", "wheel",
"--no-input", "--disable-pip-version-check", "--no-index", "--no-deps",
"--no-build-isolation", "--wheel-dir", str(wheel_dir), str(roots[0]),
],
)
wheels = list(wheel_dir.glob("*.whl"))
if len(wheels) != 1:
raise PolicyError("source build must produce exactly one wheel")
self.store.validate_wheel(plan, wheels[0])
return wheels[0]
def _cache_wheel(self, plan: ReleasePlan, wheel: Path) -> tuple[Path, dict[str, object]]:
digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
cache = (
self.config.paths.allowed_root
/ ".netbox-store-wheels"
/ plan.plugin.slug
/ plan.approved_payload_sha256
)
cache.mkdir(parents=True, exist_ok=True, mode=0o700)
destination = cache / wheel.name
if destination.exists() and destination.is_symlink():
raise PolicyError("wheel cache destination may not be a symlink")
temporary = cache / (wheel.name + ".tmp")
shutil.copyfile(wheel, temporary)
os.chmod(temporary, 0o600)
os.replace(temporary, destination)
requirement = {
"package_name": plan.plugin.package_name, "version": plan.version,
"download_url": destination.as_uri(), "filename": destination.name,
"sha256": digest, "size": destination.stat().st_size,
}
return destination, requirement
def _pip_uninstall(self, operation_id: str, package_name: str) -> None:
self._run(
operation_id,
@@ -184,23 +257,26 @@ class OperationProcessor:
if existing is not None:
self._same_identity(existing, plan.plugin)
enabled = existing.enabled if existing else False
managed = ManagedPlugin(
slug=request.plugin_slug,
package_name=plan.plugin.package_name,
import_name=plan.plugin.import_name,
version=plan.version,
enabled=enabled,
requirements=(plan.requirement(),),
)
temp_root = self.config.paths.temp_dir
if temp_root.is_symlink():
raise PolicyError("temporary directory may not be a symlink")
temp_root.mkdir(parents=True, exist_ok=True, mode=0o700)
with tempfile.TemporaryDirectory(prefix="operation-", dir=temp_root) as temporary:
self._event(operation_id, "artifact", "Downloading and verifying approved wheel")
wheel = self.store.download_release(plan, Path(temporary))
self._event(operation_id, "artifact", "Downloading and verifying approved artifact")
artifact = self.store.download_release(plan, Path(temporary))
if self.config.agent.dry_run:
return self._dry_result(request, version=plan.version, artifact_verified=True), False
wheel = (
self._build_source_wheel(operation_id, Path(temporary), plan, artifact)
if plan.artifact_kind == "source_archive"
else artifact
)
wheel, requirement = self._cache_wheel(plan, wheel)
managed = ManagedPlugin(
slug=request.plugin_slug, package_name=plan.plugin.package_name,
import_name=plan.plugin.import_name, version=plan.version, enabled=enabled,
requirements=(requirement,), artifact_lock=plan.artifact_lock(),
)
self._mark_mutated(operation_id)
host_mutated = True
self._pip_install(operation_id, Path(temporary), managed, wheel)
@@ -239,6 +315,7 @@ class OperationProcessor:
existing.version,
True,
existing.requirements,
existing.artifact_lock,
)
if self.config.agent.dry_run:
return self._dry_result(request, version=existing.version, enabled=True), False
@@ -260,6 +337,7 @@ class OperationProcessor:
existing.version,
False,
existing.requirements,
existing.artifact_lock,
)
if self.config.agent.dry_run:
return self._dry_result(request, version=existing.version, enabled=False), False
+14 -2
View File
@@ -31,6 +31,7 @@ class ManagedPlugin:
version: str
enabled: bool
requirements: tuple[dict[str, Any], ...]
artifact_lock: dict[str, Any] | None = None
class Journal:
@@ -111,10 +112,17 @@ class Journal:
version TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)),
requirements_json TEXT NOT NULL,
artifact_lock_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL
);
"""
)
columns = {row[1] for row in connection.execute("PRAGMA table_info(managed_plugins)")}
if "artifact_lock_json" not in columns:
connection.execute(
"ALTER TABLE managed_plugins ADD COLUMN artifact_lock_json "
"TEXT NOT NULL DEFAULT '{}'"
)
@staticmethod
def _operation_dict(row: sqlite3.Row, events: list[dict[str, Any]] | None = None) -> dict[str, Any]:
@@ -306,6 +314,7 @@ class Journal:
version=row["version"],
enabled=bool(row["enabled"]),
requirements=tuple(requirements),
artifact_lock=json.loads(row["artifact_lock_json"]),
)
def get_managed_plugin(self, slug: str) -> ManagedPlugin | None:
@@ -322,18 +331,20 @@ class Journal:
def upsert_managed_plugin(self, plugin: ManagedPlugin) -> None:
requirements_json = canonical_json(list(plugin.requirements))
artifact_lock_json = canonical_json(plugin.artifact_lock)
with self._connect() as connection:
connection.execute(
"""
INSERT INTO managed_plugins(
slug, package_name, import_name, version, enabled, requirements_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
slug, package_name, import_name, version, enabled, requirements_json, artifact_lock_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET
package_name=excluded.package_name,
import_name=excluded.import_name,
version=excluded.version,
enabled=excluded.enabled,
requirements_json=excluded.requirements_json,
artifact_lock_json=excluded.artifact_lock_json,
updated_at=excluded.updated_at
""",
(
@@ -343,6 +354,7 @@ class Journal:
plugin.version,
int(plugin.enabled),
requirements_json,
artifact_lock_json,
utc_now(),
),
)
@@ -144,6 +144,7 @@ class ManagedFiles:
def render_requirements(self, plugins: Iterable[ManagedPlugin]) -> bytes:
requirements: dict[str, dict[str, object]] = {}
schemes = {"https", "http"} if self.config.store.allow_http_for_testing else {"https"}
schemes.add("file")
for plugin in plugins:
for raw in plugin.requirements:
if not isinstance(raw, dict):
@@ -166,10 +167,17 @@ class ManagedFiles:
if not isinstance(url, str) or any(char in url for char in "\r\n\t "):
raise ValidationError("managed requirement URL is invalid")
parsed = urlsplit(url)
remote_invalid = parsed.scheme != "file" and (
not parsed.hostname or parsed.hostname.lower() not in self.config.store.allowed_hosts
)
local_path = Path(parsed.path.lstrip("/") if os.name == "nt" else parsed.path)
local_invalid = parsed.scheme == "file" and not is_relative_to(
local_path.resolve(strict=False), self.config.paths.allowed_root
)
if (
parsed.scheme not in schemes
or not parsed.hostname
or parsed.hostname.lower() not in self.config.store.allowed_hosts
or remote_invalid
or local_invalid
or parsed.username
or parsed.password
or parsed.fragment
+2
View File
@@ -88,6 +88,8 @@ def release_json(version: str = "1.2.3", **overrides: Any) -> dict[str, Any]:
"download_url": f"http://artifacts.test/demo_plugin-{version}-py3-none-any.whl",
"sha256": "a" * 64,
"artifact_size": 100,
"artifact_kind": "wheel",
"artifact_filename": f"demo_plugin-{version}-py3-none-any.whl",
"commit_sha": "",
"min_netbox_version": "4.6.5",
"max_netbox_version": "4.6.8",
+32
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import hashlib
import io
import tarfile
import tempfile
import unittest
from pathlib import Path
@@ -48,6 +50,36 @@ class CatalogTests(unittest.TestCase):
plan = StoreClient(self.config, transport).get_release("demo-plugin", "1.2.3")
self.assertEqual(plan.version, "1.2.3")
def test_commit_bound_source_archive_is_verified(self) -> None:
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
content = b"[build-system]\nrequires = []\n"
info = tarfile.TarInfo("demo/pyproject.toml")
info.size = len(content)
archive.addfile(info, io.BytesIO(content))
artifact = buffer.getvalue()
commit = "d" * 40
release = release_json(
download_url=f"http://artifacts.test/demo/archive/{commit}.tar.gz",
artifact_kind="source_archive",
artifact_filename="demo-plugin-1.2.3-source.tar.gz",
commit_sha=commit,
sha256=hashlib.sha256(artifact).hexdigest(),
artifact_size=len(artifact),
)
transport = FakeTransport(
{
"http://store.test/api/v1/plugins/demo-plugin": plugin_json(),
"http://store.test/api/v1/plugins/demo-plugin/releases/1.2.3": release,
},
artifact,
)
client = StoreClient(self.config, transport)
plan = client.get_release("demo-plugin", "1.2.3")
directory = self.root / "source-operation"
directory.mkdir()
self.assertEqual(client.download_release(plan, directory).read_bytes(), artifact)
def test_unapproved_or_mutable_release_rejected(self) -> None:
for change in ({"approved": False}, {"immutable": False}, {"status": "pending"}):
with self.subTest(change=change):