Securing CI/CD for an open source project: Locking down dependencies
Source: CNCF Blog
Posted on June 12, 2026 by André Martins (Cilium maintainer and Software Engineer, Isovalent at Cisco) and Feroz Salam (Cilium Security Team and Security Engineer, Isovalent at Cisco)
CNCF projects highlighted in this post

Part two
This is the second post in a three-part series on how Cilium hardens its CI/CD pipeline. Part 1 covered access control: who can trigger builds and what code CI is allowed to execute. This post covers the dependency layer: what code those builds pull in, and how we make sure it hasn’t been tampered with.
Locking down dependencies
Once you control who triggers builds, the next question is what code those builds pull in. A pinned workflow that fetches a compromised dependency is still a compromised workflow.
Pinning GitHub Actions by SHA digest
The single highest-leverage thing any project can do here is stop trusting mutable tags. Every uses: directive in our workflow files references actions by full 40-character commit SHA, with the human-readable version stuck on the end as a comment:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
If somebody compromises the v6 tag on actions/checkout and force-pushes malicious code, our workflows won’t pull it. They’re pinned to a specific commit. Same story for every third-party action we use: docker/build-push-action, sigstore/cosign-installer, golangci/golangci-lint-action,and dozens more. We pin container images used directly in workflow steps the same way, by @sha256: digest, so even the tools we run inside CI are content-addressed.
Pinning has one annoying blind spot, which is transitive dependencies. When we pin actions/checkout@de0fac2e… we know exactly which code runs for that action. But if actions/checkout itself references another action by tag (uses: some-org/some-helper@v1), that resolution happens at runtime and is invisible to us. An attacker who pops the nested dependency can still reach our pipeline.
A fix is on the way: workflow-level dependency locking was announced in GitHub’s 2026 Actions security roadmap. It would add a dependencies: section to workflow YAML that locks all direct and transitive action dependencies by commit SHA, similar to what go.mod + go.sum do for Go. We’ll adopt it as soon as it ships.
Automated updates with a trust boundary
Maintaining SHA pins by hand would be miserable, so we don’t. Our Renovate configuration extends the helpers: pinGitHubActionDigests preset and sets pinDigests: true globally. When a new action version drops, Renovate opens a PR bumping the SHA. We stay current without ever falling back to a mutable ref.
Renovate runs as a self-hosted bot on an hourly schedule, using a dedicated GitHub App with fine-grained permissions instead of a personal access token. vulnerabilityAlerts is on, so known CVEs in the dependency tree turn into PRs straight away.
We recently added a Renovate cooldown so we don’t pick up brand-new releases the moment they appear. Given the current pace of supply chain attacks, those few days are usually the window in which a compromised package gets noticed and yanked:
.github/renovate.json5
{
// Dependency cooldown: skip versions published less than 5 days ago
"matchUpdateTypes": ["major", "minor", "patch"],
"minimumReleaseAge": "5 days"
},
{
"matchPackageNames": [
"actions/{/,}**", // GitHub's official actions
"docker/{/,}**", // Official Docker actions
"cilium/{/,}**", // Our own ecosystem
"k8s.io/{/,}**", // Kubernetes official
"sigs.k8s.io/{/,}**", // Kubernetes SIGs
"golang.org/x/{/,}**", // Go experimental
"github.com/golang/{/,}**", // Go official org
"github.com/prometheus/{/,}**",
"github.com/hashicorp/{/,}**",
"go.etcd.io/etcd/{/,}**",
// ...trimmed
],
"automerge": true,
"automergeType": "pr",
"groupName": "auto-merge-trusted-deps",
"reviewers": ["ciliumbot"]
}
Updates from this allow-list auto-merge after CI passes. Everything else needs a human review.
The auto-approve workflow adds another belt-and-suspenders check: it verifies that the PR was created by cilium-renovate[bot] and that the review request was actually triggered by the bot itself, not by a human pretending to be it:
if: ${{
github.event.pull_request.user.login == 'cilium-renovate[bot]' &&
(github.triggering_actor == 'cilium-renovate[bot]' ||
github.triggering_actor == 'auto-committer[bot]')
}}
If those conditions don’t hold, no auto-approval happens.
Go module vendoring
All Go dependencies are vendored and committed to the repo. CI verifies there’s no drift between go.mod, go.sum, and vendor/. Builds are reproducible and don’t talk to external module proxies at build time, so a tampered module on a proxy never reaches us. We also run license checks (go run ./tools/licensecheck) to keep dependencies with unwanted licenses out of the tree.
Would forking actions into our own org be even safer?
In theory, yes. If we forked every third-party action into cilium/ and pinned to our own fork’s SHA, an upstream compromise wouldn’t reach us at all. Some high-security projects do exactly this.
We’ve decided against it, mostly because the operational cost is real and the security win is smaller than it first looks:
-
Maintenance burden. We use dozens of third-party actions. Keeping forks in sync with upstream security patches becomes a part-time job, and a stale fork with unpatched vulnerabilities is itself a security problem.
-
Missed improvements. Upstream actions regularly fix bugs and ship security features. Forks add friction to picking those up.
-
Renovate complexity. Our update pipeline would have to track upstream releases, open PRs against each fork, and then update the consuming workflows. The chain doubles in length.
SHA pinning gives us the immutability guarantee that actually matters: a specific commit is a specific commit, regardless of which org hosts it. Combined with Renovate proposing updates as new versions come out, we get the security benefit without the operational tax. If a major action provider got repeatedly compromised, forking the high-risk ones is a reasonable escalation, but we haven’t been pushed to that point.
The same tradeoff applies to Go dependencies
The “should we fork it?” question applies just as much to our Go dependency tree. Cilium pulls in hundreds of Go modules: Kubernetes client libraries, gRPC, etcd, Prometheus, the works. Forking and maintaining all of them isn’t realistic.
Go is in a slightly better starting position than npm or PyPI because import paths explicitly include the source (github.com/stretchr/testify), which kills off the Dependency Confusion attack class entirely. Typosquatting is still a real threat, though. Michael Henriksen’s research found typosquatted Go packages in the wild, including a fork of urfave/cli registered as utfave (one transposed letter) that phoned home with hostname, OS, and architecture. Swapping that callback for a reverse shell would have been a one-line change.
And typosquatting isn’t the worst case. SolarWinds showed that a legitimate, widely-trusted vendor can have its build pipeline compromised and then push malware through normal updates. Same can happen to any Go module: an attacker who gets into a maintainer’s account publishes a malicious release, the proxy caches it, and anyone running go get pulls it in. That’s why we vendor: it moves the trust decision from build time, where it’s invisible, to review time, where a human can see the diff.
Vendoring is the main defense here. A typosquatted import path shows up as a diff in vendor/ during code review instead of silently resolving from a module proxy. It doesn’t catch the typo at the moment it’s introduced (it relies on a reviewer noticing the unfamiliar path in the PR), but combined with CODEOWNERS gating it has held up well so far.
We’re also deliberate about which dependencies we take on. The Renovate config has an explicit list of disabled dependencies that we manage by hand, either because they need coordinated updates (like sigs.k8s.io/gateway-api alongside conformance tests), because we maintain a fork with project-specific patches (like github.com/cilium/dns), or because the dependency is one we develop ourselves and want to bump deliberately (like github.com/cilium/ebpf), which isn’t a fork but a standalone Go library maintained under the Cilium org). Changes to vendor/ are reviewed by the dedicated @cilium/vendor team via the same CODEOWNERS mechanism above.
There’s a Go proverb worth quoting here: “A little copying is better than a little dependency.” We periodically audit our third-party libraries and actively shrink the tree. If a dependency exists only to provide a small utility function, we replace it with a few lines copied inline. Every dependency you remove is one that can never be compromised and reviewing future dependency changes gets easier.
Catching mistakes with static analysis
Even with the right policies in place, mistakes happen. A well-meaning contributor can add a workflow without permissions:, or use ubuntu-latest instead of a pinned runner. We use static analysis to catch this stuff before review.
Where workflows need write access (release signing, OIDC for Cosign), they declare only the specific scope they need, like id-token: write or contents: write. Where they don’t, they declare permissions: read-all or permissions: {} to opt out of the broader defaults. We don’t rely on memory for this, though. CodeQL runs on every push and PR with the actions/missing-workflow-permissions rule turned on, and the workflow fails any modified workflow file that doesn’t set permissions explicitly.
On top of that, actionlint statically checks every workflow file for syntax errors, unsafe patterns, and misconfigurations. The same lint pipeline also enforces project conventions: every job and step has a name, no job uses the floating ubuntu-latest runner tag (we pin to ubuntu-24.04), and there’s no trailing whitespace in workflow files.
One vulnerability class is worth singling out: GitHub Actions expression injection. The ${{ }} syntax in workflow YAML is a text substitution that happens before bash sees the line at all. If an attacker controls the value being substituted (a PR title, a branch name), they can inject arbitrary shell commands via ;, $(…), or backticks. Bash has no idea where the value came from. The fix is to assign the value to an environment variable first and reference it as “$MY_VAR” in the run: block, so bash treats it as a single variable regardless of contents. The GitHub security team reported this to us a while back, and we fixed every instance. It’s a subtle bug that’s easy to introduce and hard to spot in review, which is exactly why static analysis matters: both actionlint and CodeQL flag ${{ }} usage in run: blocks where untrusted input flows in.
Part 3 will cover the final layer: keeping CI and production credentials isolated, signing and attesting every release, and the gaps we’re still working to close.
*André Martins is a Cilium maintainer and Software Engineer, Isovalent at Cisco. Feroz Salam is a member of the Cilium Security Team and a Security Engineer, Isovalent at Cisco. Find Cilium on **GitHub *and join the community on Slack.