Part one

The past year has been a challenging one for open source supply chains. The Axios package on npm was compromised, delivering a remote access trojan disguised within seemingly legitimate releases. LiteLLM’s PyPI package was taken over to steal environment variables. Malicious copies of Trivy were uploaded to PyPI, designed to catch users who mistype commands during installation. And the 2020 SolarWinds attack remains the go-to warning story: intruders infiltrated the build system and distributed malware through standard Orion updates to approximately 18,000 organizations, including U.S. federal agencies, NATO, and Microsoft. The malicious code remained inactive for months, and the breach went unnoticed for nearly a year.
Cilium operates at the kernel-level networking layer for millions of Kubernetes pods. A supply chain compromise here would have massive consequences. Protecting the project against such threats is an ongoing priority, and we wanted to document our approach in detail. Most of these practices aren’t unique to Cilium: any open source project using GitHub Actions for CI/CD can implement similar measures. We’ve also been transparent about areas where we still have room for improvement, hoping this might serve as a helpful reference for others.
This is the first installment of a three-part series. This article focuses on access control: who can initiate builds and what code CI systems are permitted to run. Part 2 will address dependency security, and Part 3 will cover credential separation, release validation, and the remaining gaps we’re working to close.
TL;DR
Short on time? Here’s a summary of Cilium’s current supply chain security measures, organized by pipeline layer:
| Layer | Control | What it does |
| Who triggers builds | Trigger control via Ariane | Only verified organization members can initiate CI workflows from PR comments, and only from an explicitly approved list of workflows. |
| What code CI executes | Two-phase checkouts for pull_request_target | Trusted code (composite actions, scripts, signing logic) is pulled from the base branch; the PR head is only used as Docker build context, never executed as a script. |
| Who reviews CI changes | CODEOWNERS gates | Any changes under .github/ require review from the security-focused CI team, and auto-approve.yaml requires a maintainer. |
| What dependencies CI pulls in | SHA-pinned actions and images | Every uses: references a 40-character commit SHA; container images are pinned by @sha256: digest. Renovate keeps the pins fresh and waits 5 days before picking up new releases. |
| What Go modules ship in the binary | Vendored Go dependencies | Everything is checked into vendor/ and reviewed by the @cilium/vendor team, so a typosquatted or hijacked module shows up as a diff at review time. |
| What workflows are even allowed to look like | Static analysis on workflows | CodeQL enforces explicit permissions: on every workflow, actionlint catches unsafe patterns, and both flag GitHub Actions expression injection in run: blocks. |
| What credentials are reachable | CI vs. production credential isolation | CI credentials can only push to *-ci development tags; production registry credentials sit behind a protected release environment that requires maintainer approval. |
| What consumers can verify | Signed releases | Every release image and Helm chart is signed with Sigstore Cosign using keyless OIDC, with SBOM attestations attached. |
| Where we still fall short | Gaps we’re still closing | No SLSA provenance yet, no PR-time dependency review, no govulncheck in CI, and a handful of internal @main references that need to move to a dedicated composite-actions repo. |
Managing who can run what
The starting point for any CI supply chain discussion is: who has the ability to trigger a build, and what code does that build actually execute? Many CI security incidents begin right at this stage, by manipulating the system into running attacker-controlled code with elevated permissions.
Workflow trigger restrictions with Ariane
Ariane is a custom-built GitHub bot we developed to dispatch CI workflows from PR comments. When a maintainer enters /test or /ci-eks on a pull request, Ariane verifies that the commenter is part of the organization-members team, determines which workflows to trigger (including any dependencies, such as tests that require a fresh image build first), and dispatches them via workflow_dispatch.
The key feature is the allow-list. Only verified organization members can trigger workflows, and the specific workflows available for triggering are manually defined in the configuration:
.github/ariane-config.yamlallowed-teams:
- organization-members
triggers:
/tests*:
workflows:
- conformance-aws-cni.yaml
- conformance-clustermesh.yaml
- conformance-eks.yaml
# ...and so on
depends-on:
- /build-images-dependency
/ci-aks:
workflows:
- conformance-aks.yaml
depends-on:
- /build-images-dependency
An external contributor typing /test in a PR is simply ignored. They can’t trigger our resource-intensive cloud-provider conformance test suites or consume our CI minutes.
Separating trusted and untrusted code in CI
When someone submits a PR, we need to build their code, but we can’t fully trust it. This is the well-known pull_request_target challenge. We minimize our use of pull_request_target where possible, but some workflows still require it, and we implement compensating controls around those cases.
The image build workflow serves as the typical example. It divides the checkout process into two stages:
.github/workflows/build-images-ci.yaml
- name: Checkout base or default branch (trusted)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${ github.base_ref }
persist-credentials: false
# ...trusted setup steps run here, including loading composite actions...
# Warning: since this is a privileged workflow, subsequent workflow job
# steps must take care not to execute untrusted code.
- name: Checkout pull request branch (NOT TRUSTED)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ steps.tag.outputs.sha }}
The initial checkout retrieves the base branch—code that has already undergone review and been merged—so we can load our composite actions, scripts, and Cosign signing logic from a verified, reliable source. Only after this step does the workflow check out the PR head, and that checkout serves exclusively as build context for docker build. No code from the PR branch is ever run as a script.
We receive security reports about this pattern on a regular basis. Automated scanners and well-intentioned researchers see “pull_request_target combined with a second checkout” and flag it as a vulnerability. In the general case, they’re correct. In our case, the workflow is deliberately architected so the pattern remains safe:
- No
run:steps execute scripts from the untrusted checkout. Every shell block following the second checkout is written directly inline in the workflow YAML (disk usage checks, file copies, digest output). Nothing is sourced from the PR branch. - No composite actions are loaded from the untrusted checkout either. All composite actions (set-runtime-image, cosign, set-env-variables) originate from the trusted base-branch checkout or from the saved
../cilium-base-branch/directory. We’re also in the process of migrating these composite actions into a dedicated repository so we won’t need to check out source code just to run them at all. - Docker BuildKit does execute the untrusted Dockerfile, and that’s precisely the purpose of building a CI image from a PR. BuildKit operates in isolation: no GitHub Actions environment variables, no repository secrets, no access to the runner’s Docker credential store. The build arguments we pass contain no secrets—only the runtime image reference and the operator variant name.
- Untrusted data flows into exactly one trusted action. The
runtime-image*.txtfile from the PR is passed into the trustedset-runtime-imageaction, which verifies the image reference begins withquay.io/cilium/and strips newlines to prevent an attacker from injecting aGITHUB_ENVexploit. There’s no way to redirect the build to anything outside the Cilium namespace. - Only CI credentials are accessible within the workflow. The Docker login uses
QUAY_USERNAME_CI/QUAY_PASSWORD_CI, which can only push to the-cidevelopment registry. Production credentials are not present on the runner at all.
The worst-case scenario of a compromised PR build is a malicious CI image ending up in the development registry—which represents the same blast radius any CI system that builds contributor code inherently carries. We genuinely appreciate every report and review each one carefully, but this pattern is by design.
CODEOWNERS as a review gate
We rely heavily on CODEOWNERS to ensure that changes always land in front of the people who have the deepest context. For CI configuration, this means everything under .github/ is owned by @cilium/github-sec (our security-focused CI team) along with @cilium/ci-structure, and the auto-approve.yaml workflow is owned by @cilium/cilium-maintainers:
CODEOWNERS
/.github/ @cilium/github-sec @cilium/ci-structure
/.github/ariane-config.yaml @cilium/github-sec @cilium/ci-structure
/.github/renovate.json5 @cilium/github-sec @cilium/ci-structure
/.github/workflows/ @cilium/github-sec @cilium/ci-structure
/.github/workflows/auto-approve.yaml @cilium/cilium-maintainers
No one can modify the CI pipeline without an explicit review from the team responsible for keeping it secure.
Coming up in Part 2, we’ll cover how we lock down what code builds actually pull in: SHA-pinned actions, automated dependency updates, and Go module vendoring.
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.



