Skip to main content

Supply chain

What this project depends on, what it promises about those dependencies, and what stops a compromised or broken release from reaching a build. If you are reporting a vulnerability rather than reading about dependencies, start at SECURITY.md.

The two kinds of version number

Rust projects carry two, and confusing them is the usual source of bad advice.

WhereWhat it isWho it binds
RequirementCargo.tomltokio = "1.52" — a rangeEveryone who depends on us
Locked versionCargo.locktokio 1.52.4 — one exact buildThis repository only

Cargo.lock is committed and every CI build passes --locked, so a build here cannot silently resolve a different graph than the one that was reviewed. But the lockfile is not published and plays no part in a consumer's resolution. When you add etl to your project, your resolver reads our requirements and picks versions itself.

That is why nothing in [workspace.dependencies] is exact-pinned. An =1.2.3 requirement in a published library is imposed on every consumer, cannot be overridden by them, and makes the crate unresolvable alongside anything else wanting a different patch. The only exact pins are on this repository's own crates, which move in lockstep.

Floors are a promise, and they are tested

A requirement of tokio = "1.52" claims the framework works with 1.52.0 and everything compatible above it. That claim is checked: a nightly job resolves every direct dependency to the lowest version its requirement admits and builds the workspace. If a floor names a version that cannot work, the job fails.

The check is one-sided, and worth knowing. It catches a floor that is too low. A floor that is needlessly high still builds — it just narrows what consumers can resolve against — and no automated gate can detect that.

MSRV is Rust 1.94, on a rolling N-2 policy, checked in CI against the committed lockfile.

What must not leak into the public API

The full rule and its rationale are in docs/DESIGN.md under Dependency policy. In short: no rdkafka, clickhouse, or apache-avro type appears in a public trait bound or public struct of etl-core, because those are 0.x crates and their breaking releases must not become ours. The single exception inside etl-core is the metrics facade, whose handle types are the instrumentation API, re-exported so exactly one version of it lives in the tree.

The scope matters. Connector crates may deliberately re-export their underlying crate, and some do — etl-avro exposes

pub type AvroValue = apache_avro::types::Value;

reachable as etl::avro::AvroValue, so an apache-avro 0.x release can break code written against it. Those re-exports are marked in the rustdoc as exempt from our stability promises, and they are the one place where a 0.x dependency's breaking change becomes yours. If you are doing semver due diligence, that is the surface to look at — not etl-core.

The controls

Each of these exists because of a specific failure, not as a checklist item.

Committed lockfiles, enforced

Both Cargo.lock and website/package-lock.json are committed. Every dependency-resolving cargo invocation in CI passes --locked, and the docs site installs with npm ci. Without this the lockfile is documentation: a job can re-resolve and rewrite it in the runner, and nobody finds out.

Two invocations are deliberately outside that, each recorded inline where it appears. cargo hack --no-dev-deps rewrites each Cargo.toml while it runs, which makes the lockfile need an update and fails --locked outright. And the minimal-version job runs cargo generate-lockfile -Z direct-minimal-versions, whose whole purpose is to produce a different lockfile — it then builds that one with --locked. In both cases the lint, test, deny and MSRV jobs run --locked against the same commit, so lockfile drift is still caught.

Actions pinned to commit SHAs

Every uses: in every workflow is a full 40-character commit SHA with a version comment, never a tag or branch. A mutable ref executes whatever the upstream repository points at right now — there is no release event, so no cooldown or review policy can gate it.

Pinning has a cost: you stop receiving upstream fixes automatically. The compensating control is a zizmor job that fails CI on a known-vulnerable action, an unpinned ref, a missing permissions: block, or a checkout that persists credentials. It runs with a token, because without one it silently skips the audits that need the API and reports success.

Pinning the action is only half of it, because an action that installs a tool can still move which tool you get. The ones whose behaviour a gate rests on are therefore pinned by tool version as well: cargo-deny (which reports the security verdict), zizmor (whose default audit policy is the guarantee), and cargo-about (which feeds a byte-exact diff).

Scoping never applies to the security gates

CI runs only the jobs a change can affect — a documentation edit does not build the workspace, and a change confined to one connector does not boot every container. That scoping is deliberately not extended to cargo deny, zizmor, or the THIRD-PARTY.md diff gate, and the reason is worth stating because the alternative looks reasonable.

A filter keyed on Cargo.lock and deny.toml would seem to cover cargo deny, and does not: enabling a feature can pull a new crate into the --all-features graph through a path no glob sees, and the advisory database changes with no file in this repository changing at all. Filtering zizmor is worse than incomplete — what it guards against is a pull request that edits .github/, so gating it on .github/ having changed is circular. Those three run on every pull request, unconditionally.

The same rule decides what may move to the scheduled tier: a check whose job is to stop something entering main has to run before it enters main, because a scheduled failure arrives after the merge that caused it. The scheduled tier is for the opposite case — the world changing underneath a tree that has not.

Advisories, on a schedule as well as on pull requests

cargo deny check advisories runs on every pull request and nightly. The nightly run is the one that matters: an advisory published against a crate already in Cargo.lock is invisible to a pull-request-triggered gate until somebody happens to open one. A failing nightly opens a tracking issue rather than going unnoticed at 03:17.

Updates arrive after a cooldown

Dependabot proposes updates weekly, per ecosystem. A release must have sat on the registry for a soak period before it is proposed — the window in which a compromised or simply broken publish is normally caught and yanked.

EcosystemPatchMinorMajor
cargo, npm7 days14 days14 days
github-actions, docker7 days7 days7 days

The minor column is 14 rather than 7 deliberately. Cooldown classifies a bump positionally — by which number moved — while Cargo's caret rules make 0.39 → 0.40 a breaking change. Most of this dependency surface is 0.x, so without the longer minor soak the breaking releases would get the shortest grace period. The cost is that genuinely compatible 1.x minors wait 14 days too.

Actions and Docker get a flat 7 days because GitHub does not support the per-position keys for those ecosystems.

Security updates bypass the cooldown entirely, so a fix for a known advisory is never delayed by it. Nothing auto-merges.

npm lifecycle scripts are off

website/.npmrc sets ignore-scripts=true. Install hooks are how npm supply-chain worms propagate: they run on npm ci, before any of the project's own code, in a job with a checkout and a network. The docs site's ~1400 packages are the largest dependency graph in the repository.

An .npmrc only covers installs that can see it, which is not all of them. The docs deploy step runs a second npm install — cloudflare/wrangler-action shells out to npm i wrangler@4 in a job with no checkout, so no .npmrc is on disk and the production deploy token is already in the environment. That one is covered separately, by setting NPM_CONFIG_IGNORE_SCRIPTS on the step, since npm reads its configuration from the environment as well as from a file.

A new dependency cannot arrive unnoticed

THIRD-PARTY.md lists every crate in the shipped tree, is regenerated by CI, and is byte-exact diff-gated. It is usually described as a licence artifact, but it is also the control that makes the dependency graph visible: no crate can enter the tree, from any source and at any depth, without producing a diff that a human has to look at and accept. Combined with deny.toml's unknown-registry = "deny" and unknown-git = "deny", a dependency cannot come from anywhere but crates.io.

Accepting an advisory

deny.toml may carry an ignore entry for an advisory, and doing so is a deliberate act with a written argument attached. Each entry needs:

  1. Why the vulnerable path is not reachable, or why the exposure is bounded. Not "it looks minor" — what the code actually does in this tree.
  2. Why there is no upgrade. Usually an upstream release that has not landed yet, named explicitly.
  3. The condition for removing it. An ignore without an exit is a permanent silent acceptance.

The reason field is the machine-readable half and shows up in tooling output, so it has to stand alone rather than depend on the comment above it.

Current entries: one unmaintained proc-macro reached through clickhouse (compile-time only, so it contributes no runtime or network surface), and two denial-of-service advisories against quick-xml under object_store, where the XML being parsed is the operator's own object store's list responses. All three are conditional on an upstream release.

What is not covered

Stated plainly, so it is not mistaken for coverage:

  • npm dependencies have no advisory gate in CI. cargo deny covers the Rust tree only. Alerts for the docs site's npm packages come from GitHub's dependency graph instead, which reads the committed lockfile — detection is covered, enforcement is not, so an advisory there cannot fail a build.

    Automated fixes are enabled but do not close every alert: when the vulnerable package is reached only transitively, Dependabot cannot find it in a manifest and will not open a security update for it. Those need the intermediate dependency to release, or an explicit overrides entry. This affects the documentation site only — it is not part of any published crate.

  • The minimal-version job does not run doctests, and never builds the minimal graph on the MSRV toolchain.

  • Nothing is published to crates.io yet, so there is no release provenance, no signature, and no attestation to verify. That arrives with the first release.

  • Licensing — the licence allow list, and the attribution artifacts THIRD-PARTY.md and the full licence texts.
  • Installation — the feature flags that decide which dependencies your build actually links.
  • SECURITY.md — how to report a vulnerability privately.