Supply Chain Security

Your system isn't just the code you write — it's every dependency you import, every base image you build on, every CI action in your pipeline, and every registry you pull from. Each is code you run with your privileges, written by someone else, on their schedule. Supply chain security is deciding — deliberately, at a controlled point — what gets that trust, and limiting the damage when the decision is wrong.

This page is the why and the policy. The machinery lives in two practice documents: self-hosted-artifacts (the registry and image side) and flux (git as the only path to production).

Encountered at SFI: the intake policy below is live in sfi/renovate-config, applied to every repo on the internal gitea; the hosting policy is the sfi/action-runner-image → gitea registry pipeline.

The threat, concretely

The attacks worth designing against are not hypothetical, and their shape has shifted. A decade ago the canonical worry was typosquatting — requets instead of requests. Today the growth area is compromised legitimate releases: an attacker takes over a maintainer account or CI pipeline and ships a malicious version of a package you already depend on, under its real name, passing signature and checksum verification, because it is the real package.

The pattern repeats: event-stream (2018, maintainership socially engineered); the xz-utils backdoor (2024, a years-long persona targeting sshd via a compression library); the September 2025 npm wave, where a phished maintainer account shipped malicious versions of chalk and debug — packages with billions of weekly downloads — followed within weeks by the self-replicating Shai-Hulud worm harvesting tokens through postinstall scripts.

Two properties of these incidents drive the policy below. First, the malicious version is newest: the attack ships as a fresh release and depends on victims updating quickly. Second, detection is fast: high-profile compromises are typically caught in hours to days, because the ecosystem is watching popular packages. The window between release and detection is the kill zone — and it's avoidable.

Policy 1: Controlled intake

All version movement goes through one funnel: renovate, running against the internal gitea, filing PRs subject to normal review. No dependency changes outside it; lock files (go.sum, package-lock.json, uv.lock) are committed so every build uses exactly what was reviewed.

The load-bearing setting is the cooldown. From sfi/renovate-config/default.json, inherited by every repo:

{
  "minimumReleaseAge": "7 days",
  "vulnerabilityAlerts": {
    "labels": ["security"],
    "schedule": ["at any time"],
    "minimumReleaseAge": "0 days"
  }
}

A release must be seven days old before renovate will propose it. Nearly every compromised-release incident to date was detected and pulled well inside that window — the malicious chalk versions lived about two hours. Deliberately running a week behind the bleeding edge costs almost nothing in feature terms and removes you from the day-1 blast radius entirely. The exemption matters as much as the rule: known vulnerabilities skip the cooldown and the schedule, because for a published CVE the risk inverts — the exploit is public and waiting is the exposure.

Cadence does the rest of the shaping: minors and patches arrive grouped weekly (one reviewable PR per ecosystem, not twenty), majors monthly and gated behind dashboard approval with a changelog read. Review effort goes where the risk is.

Two honest limits. A cooldown is a bet on the ecosystem's detection speed — it did nothing against xz, where the implant sat dormant through a long con and was found by accident. And an unreviewed green-CI auto-merge would reduce the funnel to a delay line; the PR review is the control, renovate just batches it. Beyond the funnel, the standing rules still apply: minimize the tree (a ten-line utility pulling thirty transitive dependencies is a bad trade), and run govulncheck / npm audit / pip-audit in CI against the locked versions.

Policy 2: Controlled hosting

Production pulls artifacts only from infrastructure we run. Images are built or imported into the gitea OCI registry through reviewed CI; clusters never pull application images from the public internet at deploy time. This converts "whatever the public registry serves today" into "what we admitted, when we admitted it" — and makes redeployment independent of upstream availability, rate limits, and deleted tags.

Custom images are thin derived layers over maintained upstream bases, rebuilt when renovate bumps the base — so upstream security releases flow through the same reviewed funnel as source dependencies. Mechanics, tagging discipline, and failure modes: self-hosted-artifacts.

Minimal bases remain the rule regardless of where images are hosted: scratch/distroless for static binaries, slim variants otherwise, multi-stage builds so compilers and package managers never reach the runtime image. Every package you don't ship is a CVE you don't triage.

Policy 3: Build and deploy integrity

The pipeline itself is a dependency, and the same intake logic applies to it.

Pipeline definitions are code. CI workflows and Containerfiles live in the repo and get the same review as application changes — a workflow edit can exfiltrate every secret the job can read.

Pin actions immutably. Tags on CI actions are mutable pointers; a compromised action updates silently under you:

# Fragile — tag can be reassigned upstream
- uses: actions/checkout@v6

# Stable — immutable commit, tag recorded for humans
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v6.0.0

Scope build credentials. A build job gets a token that can push its image and nothing else. Registry write tokens, deploy keys, and cluster credentials live in separate scopes; a compromised dependency executing in CI should find nothing worth stealing.

Make git the only deploy path. With the fleet under flux, nothing reaches a cluster except through a reviewed commit — there is no kubectl path to smuggle an unreviewed image reference into production, and the repo is a complete, auditable record of what runs where.

Signing (cosign) and SBOM generation (syft) extend this: signatures close the gap between "CI built it" and "the cluster runs it," and an SBOM answers "which deployments contain the vulnerable version?" without archaeology. We treat both as the next increment rather than current practice — they earn their keep as the number of image consumers and clusters grows.

Things That Go Wrong

The cooldown creates a false sense of coverage. It defeats fast-burning compromised releases; it does not defeat dormant implants, malicious code in old versions, or attacks on packages too obscure for anyone to be watching. Scanners cover known CVEs; nothing here covers a competent zero-day. Defense in depth — network-isolation limiting what a compromised workload can reach — is what bounds the damage.

Controlled hosting rots into version-freezing. The registry only helps if intake keeps happening. A base image nobody has bumped in a year is a curated collection of known vulnerabilities. The renovate dashboard is the staleness alarm; check it.

The funnel leaks at the edges. curl | bash in a Containerfile, a go install in CI, an unpinned GitHub action — each is a dependency that bypasses the intake policy. Audit for fetches that don't go through a lock file or a reviewed pin.

References