Dependency policy
The idea in one line
Every piece of third-party code probectl uses is pinned to an exact version,
verified cryptographically, and upgraded deliberately by a human. The
supply chain is everything that flows into the build from outside —
libraries, compilers, base images, CI actions: code you run but didn't write.
A floating
version (@latest, an unpinned base image, a loosely-ranged package) is a
supply-chain input that nobody reviewed — so probectl doesn't allow any. This page
is the map of where the pins live and what enforces them.
Why so strict? probectl is self-hosted software that handles tenant data and, in the eBPF agent, runs code in the kernel. A compromised or surprise-broken dependency is a security and availability risk, not just a build annoyance. Pin exactly, verify, upgrade on purpose.
Product self-sufficiency and license posture
probectl's sellable product must remain complete without a separately licensed application. Native screens, APIs, storage paths, and operational workflows are the authoritative surfaces. Open-protocol compatibility is welcome, but the client stays operator-supplied and optional; an integration must not quietly become a required runtime.
For third-party code, prefer MIT and equivalently permissive licenses (BSD, ISC, Apache-2.0, 0BSD, and similar). A non-permissive runtime dependency is an architecture and commercial-distribution decision, so it requires explicit product-owner approval plus license review before it enters build, CI, packaging, Compose, Helm, or the air-gap bundle. In particular, do not bundle or pin an AGPL/GPL/SSPL/BSL dashboard product merely to complete or test a probectl surface. Build that surface natively; keep compatible APIs vendor-neutral.
Grafana is the concrete boundary example: /v1/grafana is probectl's own
tenant-safe Prometheus-compatible wire API, not a Grafana code dependency.
Files under deploy/grafana/ are optional configuration examples for operators
who already chose that client. No Grafana image, plugin, runtime, or license is
part of the probectl distribution or required for its UI/test definition of
done. scripts/check_dependency_license_policy.sh, run by
make third-party-gate, rejects a Grafana runtime reference and rejects
strong-copyleft or unknown runtime licenses in the generated inventory.
How everything is pinned
| Surface | Mechanism | Enforced by |
|---|---|---|
| Go modules | exact versions in go.mod, checksums in go.sum (verified against the Go checksum database on download) |
go build fails on any checksum mismatch |
| Dev / codegen tools (buf, protoc-gen-go / -go-grpc, golangci-lint, govulncheck) | exact versions at the top of the Makefile; installed as Go modules (so they're checksum-verified) — never @latest, never a curl-pipe install |
the proto job's generated-code drift check; the supply-pins gate |
| GitHub Actions | full commit-SHA pins (not @v3 tags) |
scripts/check_action_pins.sh (the action-pins CI job) |
Container images and Dockerfile frontends (compose, Helm, CI services, # syntax=) |
digest pins (@sha256:...) on infrastructure images, probectl production workloads, and BuildKit frontends. The optional analyzer runtime uses python:3.12.10-slim-bookworm@sha256:fd95…88db. |
the supply-pins gate (including the templated primary Helm image contract), Helm render gate, review, and the scheduled security scan |
npm (web/, browser-worker/) |
exact direct versions in package.json, resolved integrity records in package-lock.json, installed with npm ci |
the supply-pins gate rejects semver ranges in direct manifests; npm audit runs in the web and security-scan jobs |
| Go toolchain | the go directive in go.mod (exact patch), a verified upstream release |
see build/toolchain.md |
Three mechanisms recur in that table. A checksum is the cryptographic
fingerprint of a file's exact bytes — change one bit and the fingerprint
changes, so a verified download is provably the published one. A lockfile
(go.sum, package-lock.json, requirements-dev.lock) records the resolved
version and checksum of every package in the dependency tree, so an install
reproduces the recorded set instead of re-resolving whatever is newest. And a
digest pin addresses a container image by the hash of its content
(@sha256:...) rather than by a tag a registry owner can quietly re-point —
ordering by serial number instead of by model name: the model name can be
reissued on different goods; the serial number can't.
The supply-pins gate (scripts/check_supply_pins.sh, run by the action-pins
job) is the backstop that mechanically fails the build on a floating reference:
a :latest image ref anywhere under deploy/, a tag-only (non-digest) image:
or camelCase <name>Image: value under deploy/helm (e.g. the privileged agent
installerImage:), a mutable scalar or nested job container, service, or matrix
image: value in .github/workflows (unresolved image expressions fail closed),
a tag-only Dockerfile # syntax=docker/dockerfile frontend, a go install in
CI or the Makefile without an exact @vX.Y.Z, or a
pip install without exact == pins, --require-hashes, or --no-deps. It
also checks the direct npm manifests and the analyzer pyproject.toml for
range syntax (^, ~, >=, and friends), because a lockfile pins resolved
bytes but the manifest still states upgrade intent.
Upgrade cadence
Bot-proposed, human-reviewed, never auto-merged. An update bot opens the pull requests and a person reads the release notes, reviews, and merges — the bots never merge on their own and never batch unrelated bumps.
.github/dependabot.ymltracks the gomod (/and/test), npm (/weband/browser-worker), pip (/analyzer), Docker (/deploy/docker,/browser-worker) and GitHub-Actions ecosystems;.github/renovate.jsontracks the digest-pinned image references in the Compose stacks and Helm values that Dependabot's docker manager does not. Coverage is enforced byTestDependencyBotsCoverEveryLockfileAndDigestLocation(SUP-16), so a new lockfile or digest location cannot be added without a bot entry. Each bump lands as its own pull request through the full gate set (unit/integration tests, the isolation suites, fuzz-smoke, and the eBPF kernel-matrix where relevant).Patch SLA (from advisory/PR open to merge):
Critical 7 days,High 30 days,Medium/Low 90 days(best-effort, next regular review). The clock is the dependency PR's open time (or the advisory date for an unchanged pin that goes red); security bumps preempt the queue.Security releases are handled out-of-band on the same gates.
govulncheckand Trivy also run on a weekly schedule (.github/workflows/security-scan.yml) and on every PR, so a newly-disclosed vulnerability in an unchanged pin goes red on its own — you don't have to be mid-upgrade to find out.npm audit policy is explicit and expiring. Critical npm advisories always fail. High advisories fail unless they are listed in
docs/security/npm-audit-policy.json, and still before itsexpires_atdate when it is active. Every exception must match the exact complete High advisory set, advisory range, and installed version for every named package. A changed version or range therefore becomes a fresh failure instead of inheriting an old risk decision. A production exception must additionally carry an offline applicability guard. A dev-onlystandingexception may remain dormant when its exact advisory is absent; it cannot apply to production or suppress a stale active advisory after expiry.GHSA-qwww-vcr4-c8h2is accepted as documented risk for exactly React Router7.18.1and official affected range>=7.12.0 <8.3.0through2026-10-31(re-verified 2026-08-01: the only patched release is the 8.3.0 major; the newest 7.x,7.18.2, is still inside the affected range) only whilescripts/check_web_router_mode.mjsproves the shipped UI remains a client-only<BrowserRouter basename="/ui">SPA with no RSC API, RSC build dependency, RSC entrypoint, or server-action directive.GHSA-mh99-v99m-4gvghas a dev-only standing entry for exactly historical transitivebrace-expansion@1.1.16and official affected range<=5.0.7. That package was build/lint tooling only, never received untrusted product input, and has no patched 1.x. The current glob chain already uses patchedbrace-expansion@5.0.8, so the entry is dormant and accepts no present finding. A recurrence at a different version, a different advisory or range, or any production reachability fails as new work.GHSA-fx2h-pf6j-xcffis accepted as documented risk for exactly direct devDependencyvite@5.4.21and official affected range<=6.4.2through2026-09-30. It is a Windows-only alternate-path bypass in the Viteserver.fs.denydevelopment server. Vite is build tooling, not part of the shipped browser bundle or Go binaries, and the production-only audit remains unaffected. Decision of record 2026-08-01: the Vite 8 upgrade (8.2.0 line at decision time) lands before the2026-09-30boundary with the web build, bundle-budget, rendered-a11y, and coverage gates green; the minimal patched hop to6.4.3was rejected as landing two majors behind. The boundary is a landing deadline, not a rolling renewal. A different advisory or range, any installed-version change, production reachability, audit clearance, or expiry fails closed; therefore production adoption or the planned Vite bump automatically reopens the decision.The production
npm audit --omit=dev --jsonreport passes through the same policy checker with--omit-dev, so the full report owns dev-only exception freshness while production remains independently gated. Critical, another High, expiry of an active exception, a missing/malformed report, RSC adoption, version/range drift, or a non-standing exception whose advisory disappeared all fail closed.Tool pins (the
Makefileblock) are bumped deliberately and committed together with their effects — e.g. a protobuf-plugin bump ships with the regeneratedinternal/gentree in the same commit, because theprotojob fails if the committed generated code doesn't match the pinned plugins.
Risk register: pre-1.0 dependencies on privileged paths
Most dependencies are stable, post-1.0 libraries. A couple sit on sensitive paths and earn an explicit note.
cilium/ebpf v0.21.0 — the one that matters
The eBPF agent loads kernel programs through cilium/ebpf, which is pre-1.0:
its API contract explicitly allows breaking changes between minor versions. It
sits on probectl's most privileged path — the bpf() syscall, CAP_BPF (the
Linux capability that permits loading eBPF programs into the kernel).
Why this dependency is accepted, with eyes open:
- It is the de-facto-standard pure-Go eBPF library, maintained by the Cilium org and used in production by Cilium / Tetragon / Inspektor-Gadget at far larger scale than probectl. The only more-"mature" alternative is a cgo dependency on libbpf, which brings its own costs.
- Pre-1.0 here means API instability, not kernel-safety instability. The
kernel verifier (the in-kernel static checker every BPF program must pass
before it is allowed to run) — not the library — is the safety boundary for
what a loaded
program is allowed to do, and probectl's programs are observe-only, with a CI
gate (
internal/ebpf/observeonly_test.go) enforcing that invariant independently of the library. - The blast radius of a bad upgrade is availability of the eBPF plane (the agent fails to load programs), not data corruption or privilege escalation. Load failures are loud (lockdown explainers, attach-failure metrics) and the agent degrades to fixture/disabled mode rather than crashing the host.
Controls specific to this dependency:
- Exact pin in
go.mod(v0.21.0), checksum-verified like everything else. - Kernel-matrix CI — every change, including a
cilium/ebpfbump, loads and runs the real programs across the supported LTS kernel range under QEMU. An API or behavior break surfaces there, in CI, not on a customer's host. - Digest-verified embedded objects — the loader refuses tampered or stale BPF objects before any kernel call, independent of the library version.
- Upgrade rule — treat a minor-version bump of this library with the same care as a kernel bump: read the release notes for verifier/loader changes, and require kernel-matrix + fuzz-smoke + the agent-overhead bench green.
- 1.0 watch — when
cilium/ebpftags v1.x, move to it in its own PR and drop this caveat from the page.
Other notable pins
gosnmp(v1.43.2, device polling — untrusted device input): fuzzed viaFuzzSNMPPoll(internal/device/snmp_fuzz_test.go); a malformed device response must never panic the agent.- OTLP protobufs (
go.opentelemetry.io/proto): wire-compatibility on probectl's own schemas is governed by theprotojob's breaking-change gate; OTLP ingest is fuzzed for parse safety.
When NOT to add a dependency
The default is no. A new dependency has to clear all of:
- a maintained upstream,
- a license compatible with the source-available editions model (see
editions.md), - no phone-home behavior (a non-negotiable),
- and a note in the PR naming what it replaces.
Crypto never comes from a new dependency — it goes through internal/crypto
only, so a FIPS-validated module can be compiled in (also a
non-negotiable; a CI lint guard rejects
primitive imports elsewhere). And adding an external dependency is a design
discussion before the code, not a fait accompli in a feature PR — open the
discussion first (see ../CONTRIBUTING.md).
Deploy-time pinning (operators)
The repo pins what it controls; operators should pin what they deploy:
Images: shipped production Compose and the primary control-plane Helm chart require digest-pinned workload references; tag-only control images fail closed. Dockerfiles also digest-pin their base images. Resolve the signed release or approved-mirror digest before deployment:
docker inspect --format='{{index .RepoDigests 0}}' <image>The privileged eBPF agent Helm chart goes further: it refuses to render unless
image.tagincludes an@sha256:digest, and it renders the Kyverno image-integrity policy by default so admission verifies that digest's cosign signature against the probectl release workflow identity.image.allowTagOnly=trueis an explicit break-glass for already-verified private registries, and disabling the policy requires anadmission.imageIntegrity.acceptedRisknote. The standalonedeploy/admission/probectl-agent-image-integrity.kyverno.yamlmanifest is the GitOps form for clusters that manage admission policy separately from the workload chart.Python (the analyzer): the shipped sidecar runtime is hash-locked in
analyzer/requirements.lock; the test/tool set is hash-locked inanalyzer/requirements-dev.lock(generated withuv pip compile pyproject.toml --extra dev --generate-hashes). Runtime and dev dependencies inpyproject.tomluse exact==pins; CI installs the lock with--require-hashesand refuses any drift between the lock andpyproject.toml. Standalone tools (ruff,black,pyyaml,uvitself) are exact-pinned in the workflow.
Software bill of materials (SBOM)
An SBOM is the machine-readable parts list of a piece of software — every
dependency, with versions — the document a security team greps the day a CVE
drops to answer "do we ship that anywhere?". CycloneDX and SPDX are the two
standard SBOM document formats; probectl produces both at different points.
Every CI run produces a CycloneDX SBOM of the Go module graph
(probectl-sbom.cdx.json) via the sbom job, which installs cyclonedx-gomod
pinned and checksum-verified (go install ...@v1.7.0) — no third-party action.
The SBOM is uploaded as a build artifact (retained 90 days) alongside the scan
outputs, so the dependency posture is always evidenced, not asserted. Releases
additionally ship a signed SPDX SBOM as a release asset (see
releasing.md).
The human-readable license inventory is
third-party-licenses.md plus ../NOTICE,
regenerated by scripts/gen_third_party.sh from the Go module graph, the npm
lockfiles (web/ and browser-worker/), and the Python analyzer lock. Release
and air-gap evidence carries the same unified inventory beside SBOM/signature
artifacts, so an offline operator can answer "do we ship React, Playwright, or
structlog?" without network access.
Who owns web/package-lock.json (DPR-236, decision of record 2026-09-19)
npm 10, from the digest-pinned node:22-bookworm-slim image — never the npm on a
developer's machine. Regenerate and check it with the pinned toolchain:
scripts/web_npm.sh install --package-lock-only # regenerate
scripts/check_web_lock.sh # prove it installs (3s)
This is a decision rather than a preference because npm 10 and npm 11 disagree about lockfile content in a way that makes a lock written by one unusable by the other:
- npm 11 records every platform-specific optional binary (72
@esbuild/*and@rollup/*nodes) and writes them without"optional": true. npm 10 then tries to install@esbuild/netbsd-arm64on alinux/x64builder and dies withEBADPLATFORM. - npm 10 omits those nodes entirely. npm 11 then refuses the lock with
EUSAGE,Missing: @esbuild/aix-ppc64@0.21.5 from lock file, 72 times over.
Every consumer in CI is npm 10: the Dockerfile's digest-pinned node:22 web stage —
which is why a bad lock takes all eight build-images jobs and image-scan with it —
plus the three setup-node jobs at node-version: "22" and web-rendered-a11y inside
the Playwright container. The artifact is therefore generated by the tool that consumes
it, exactly as the BPF objects are generated by scripts/run-ebpf-toolchain.sh rather
than by whatever clang a laptop has.
Two guards, because the two failure shapes need different instruments:
internal/cipolicyTestWebLockHasNoNonOptionalPlatformBinaries— static, no docker, runs everywhere, catches the npm 11 shape.scripts/check_web_lock.sh—npm ci --dry-runinside the pinned image, run by thewebjob with its own SELFTEST. Whether a lock is installable is npm's own tree resolution and cannot be derived from the file; an approximation of it once passed the exact lock that broke CI, so the authoritative check is npm itself.
Never validate a lock against an already-populated node_modules. npm audit, the
audit-policy gate and the whole test suite all pass against a stale tree while npm ci
cannot install at all — that is precisely how DPR-211 shipped a lock that broke eleven
jobs, and the commit message claimed npm ci had been run when it had not.