Looking carefully at the output to complete the partial thought, this appears to be the last sentence cut off. Let me re-read the entire text, identify where the cut-off happens, and provide the full paraphrased version, ending that sentence logically and preserving the overall structure and content.
Part two
This is the second post in a three-part series on strengthening Cilium’s CI/CD pipeline. Part 1 focused on access control: who can initiate builds and what code CI is allowed to execute. This post focuses on the dependency layer: what code those builds pull in, and how we protect it from tampering.
Securing dependencies
Once you control who triggers builds, the next concern is what code those builds actually import. A workflow pinned to specific versions that unknowingly pulls in a compromised dependency is still a vulnerable workflow.
Pinning GitHub Actions by SHA digest
The single most impactful step any project can take here is to stop relying on mutable tags. Every uses: directive in our workflow files references actions by their full 40-character commit SHA, with a human-readable version appended as a comment:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2If someone compromises the v6 tag on actions/checkout and force-pushes malicious code, our workflows won’t be affected. They’re locked to a specific commit. The same approach applies to every third-party action we use: docker/build-push-action, sigstore/cosign-installer, golangci/golangci-lint-action, and many others. We pin container images used directly in workflow steps in the same way, by @sha256: digest, so even the tools we run during CI are content-addressed.
Pinning has one notable blind spot: transitive dependencies. When we pin actions/checkout@de0fac2e…, we know exactly which code is executed for that action. However, if actions/checkout itself references another action by a tag (uses: some-org/some-helper@v1), that resolution happens at runtime and is invisible to us. An attacker who compromises the nested dependency can still gain access to our pipeline.
A solution is coming: workflow-level dependency locking was announced in GitHub’s 2026 Actions security roadmap. It would introduce a dependencies: section to workflow YAML that locks all direct and transitive action dependencies by commit SHA, similar to how go.mod + go.sum work for Go. We plan to adopt it as soon as it becomes available.
Automated updates with a trust boundary
Managing SHA pins manually would be extremely tedious, so we don’t. Our Renovate configuration builds on the helpers: pinGitHubActionDigests preset and sets pinDigests: true across the board. When a new action version is released, Renovate opens a PR updating the SHA. We stay current without ever reverting to a mutable reference.
Renovate runs as a self-hosted bot on an hourly schedule, using a dedicated GitHub App with fine-grained permissions rather than a personal access token. vulnerabilityAlerts is enabled, so known CVEs in the dependency tree immediately generate PRs.
We recently introduced a Renovate cooldown to avoid picking up brand-new releases immediately. Given the increasing pace of supply chain attacks, those first few days are typically when a compromised package is detected and removed:
.github/renovate.json5,
{
"matchPackageNames": [
"actions/
github.triggering_actor == 'auto-committer[bot]')
**", // GitHub's official actions
"docker/
github.event.pull_request.user.login == 'cilium-renovate[bot]' &&
(github.triggering_actor == 'cilium-renovate[bot]' **", // 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 automatically merge after CI passes. Everything else requires manual review.
The auto-approve workflow adds an extra safety net: it confirms that the PR was created by cilium-renovate[bot] and that the review request was genuinely triggered by the bot itself, not by someone impersonating it:
if: ${}
If those conditions aren’t met, auto-approval is skipped.
Go module vendoring
All Go dependencies are vendored and committed to the repository. CI checks for consistency between go.mod, go.sum, and vendor/. Builds are reproducible and don’t contact external module proxies during build time, so a tampered module on a proxy can never reach us. We also run license checks (go run ./tools/licensecheck) to keep dependencies with incompatible licenses out of the codebase.
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 be able to affect us. Some high-security projects already follow this practice.
We’ve chosen not to take this route, mainly because the operational cost is significant and the security benefit is less substantial than it first appears:
- Maintenance overhead. We rely on dozens of third-party actions. Keeping forks synchronized with upstream security patches becomes a part-time effort, and a stale fork with unpatched vulnerabilities creates its own security risk.
- Missed improvements. Upstream actions regularly fix bugs and introduce security enhancements. Forks make it harder to incorporate those updates promptly.
- Renovate complexity. Our update pipeline would need to track upstream releases, open PRs against each fork, and then update the workflows that depend on them. The chain doubles in length.
SHA pinning provides the immutability guarantee that truly matters: a specific commit is a specific commit, regardless of which organization hosts it. Paired with Renovate proposing updates as new versions are released, we get the security advantage without the operational burden. If a major action provider were repeatedly compromised, forking the highest-risk actions would be a reasonable escalation, but we don’t see that scenario as likely enough to justify the added complexity at this time.
We haven’t reached that stage yet.
The same tradeoff applies to Go dependencies
The question of whether we should fork a dependency is just as relevant for our Go dependency tree. Cilium depends on hundreds of Go modules, including Kubernetes client libraries, gRPC, etcd, Prometheus, and more. Forking and maintaining all of them simply isn’t practical.
Go starts from a somewhat stronger position compared to npm or PyPI, because import paths explicitly reference the source (e.g., github.com/stretchr/testify), which completely eliminates the Dependency Confusion attack vector. However, typosquatting remains a genuine risk. Michael Henriksen’s research uncovered typosquatted Go packages in the wild, including a copy of urfave/cli registered as utfave (a single transposed letter) that sent hostname, OS, and architecture details to an external server. Replacing that callback with a reverse shell would have taken just one line of code.
And typosquatting isn’t even the worst-case scenario. The SolarWinds incident demonstrated that a legitimate, widely-trusted vendor can have its build pipeline compromised, allowing malware to be distributed through normal updates. The same thing can happen with any Go module: an attacker who gains access to a maintainer’s account publishes a malicious release, the proxy caches it, and anyone running go get pulls it in automatically. This is exactly why we vendor dependencies: it shifts the trust decision from build time, where it’s invisible, to review time, where a human can examine the diff.
Vendoring is our primary defense. A typosquatted import path appears as a diff in vendor/ during code review rather than silently resolving from a module proxy. It doesn’t catch the typo the moment it’s introduced (it depends on a reviewer spotting the unfamiliar path in the PR), but combined with CODEOWNERS gating, it has proven effective so far.
We’re also intentional about which dependencies we adopt. The Renovate configuration includes an explicit list of dependencies that we manage manually, either because they require coordinated updates (such as 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 on our own schedule (like github.com/cilium/ebpf, which isn’t a fork but a standalone Go library maintained under the Cilium organization). Changes to vendor/ are reviewed by the dedicated @cilium/vendor team through the same CODEOWNERS mechanism described above.
There’s a Go proverb worth repeating: “A little copying is better than a little dependency.” We regularly audit our third-party libraries and actively reduce the dependency tree. If a dependency exists solely to provide a small utility function, we replace it with a few lines of inline code. Every dependency you eliminate is one that can never be compromised, and reviewing future dependency changes becomes simpler as a result.
Catching mistakes with static analysis
Even with solid policies in place, mistakes still occur. A well-intentioned contributor might add a workflow without specifying permissions:, or use ubuntu-latest instead of a pinned runner version. We rely on static analysis to catch these issues before they reach review.
When workflows require write access (for release signing, OIDC with Cosign, etc.), they declare only the specific scope they need, such as id-token: write or contents: write. When they don’t need write access, they declare permissions: read-all or permissions: {} to opt out of the broader defaults. But we don’t leave this to memory. CodeQL runs on every push and PR with the actions/missing-workflow-permissions rule enabled, and the workflow fails if any modified workflow file doesn’t explicitly set permissions.
In addition, actionlint statically checks every workflow file for syntax errors, unsafe patterns, and misconfigurations. The same linting 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 deserves special attention: GitHub Actions expression injection. The ${{ }} syntax in workflow YAML is a text substitution that occurs before bash ever processes the line. If an attacker controls the value being substituted (such as a PR title or branch name), they can inject arbitrary shell commands using ;, $(...), or backticks. Bash has no way of knowing where the value originated. 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 its contents. The GitHub security team flagged this issue to us some time ago, and we addressed every instance. It’s a subtle bug that’s easy to introduce and difficult to catch during review, which is precisely why static analysis is so valuable: both actionlint and CodeQL flag ${{ }} usage in run: blocks where untrusted input is involved.
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 address.
André Martins is a Cilium maintainer and Software Engineer at Isovalent, Cisco. Feroz Salam is a member of the Cilium Security Team and a Security Engineer at Isovalent, Cisco. Find Cilium on GitHub and join the community on Slack.



