Objective
Shift security left by catching misconfigurations, vulnerabilities, and policy violations in CI before they reach the cluster. This pipeline implements four checkpoints: schema validation (Kubeconform), policy validation (OPA Conftest), vulnerability scanning (Trivy), and supply chain integrity (Cosign). Each checkpoint must pass for the pipeline to proceed.
Prerequisites
- GitHub repository with Actions enabled
- COSIGN_PRIVATE_KEY and COSIGN_PASSWORD stored as GitHub repository secrets
- Container registry credentials (REGISTRY_USER, REGISTRY_PASSWORD) as GitHub secrets
- A sample Dockerfile and Kubernetes manifests in the repository
Steps
Write the OPA Conftest policies
Conftest uses OPA's Rego language to evaluate Kubernetes manifests against custom policies. Create a policy directory with rules your organisation requires.
# policy/kubernetes.rego package main # Deny: containers without resource limits deny[msg] { input.kind == "Deployment" container := input.spec.template.spec.containers[_] not container.resources.limits msg := sprintf("container '%v' must have resource limits", [container.name]) } # Deny: containers with :latest tag deny[msg] { input.kind == "Deployment" container := input.spec.template.spec.containers[_] endswith(container.image, ":latest") msg := sprintf("container '%v' must not use :latest tag", [container.name]) } # Deny: deployments without readiness probe deny[msg] { input.kind == "Deployment" container := input.spec.template.spec.containers[_] not container.readinessProbe msg := sprintf("container '%v' must have a readinessProbe", [container.name]) } # Warn: no PodDisruptionBudget exists for deployment warn[msg] { input.kind == "Deployment" count(input.spec.template.spec.containers) > 0 not input.metadata.annotations["policy/pdb-required"] msg := "Consider adding a PodDisruptionBudget for this deployment" }
Write the complete GitHub Actions workflow
# .github/workflows/ci-secure.yaml name: Secure CI Pipeline on: pull_request: branches: [main] push: branches: [main] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: validate-manifests: name: Validate K8s Manifests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install kubeconform run: | curl -sSLf https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz \ | tar xz -C /usr/local/bin - name: Kubeconform schema validation run: | kubeconform \ -strict \ -summary \ -kubernetes-version 1.29.0 \ -output text \ k8s/**/*.yaml echo "Kubeconform: all manifests valid" - name: Install conftest run: | curl -sSLf https://github.com/open-policy-agent/conftest/releases/latest/download/conftest_linux_amd64.tar.gz \ | tar xz -C /usr/local/bin - name: OPA Conftest policy check run: | conftest test k8s/**/*.yaml \ --policy policy/ \ --output table # Exits non-zero if any deny rules fire build-scan-sign: name: Build, Scan, and Sign Image runs-on: ubuntu-latest needs: validate-manifests permissions: contents: read packages: write id-token: write # Required for keyless Cosign signing steps: - uses: actions/checkout@v4 - name: Log in to registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build image id: build uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} - name: Scan with Trivy uses: aquasecurity/trivy-action@master with: image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} format: table exit-code: 1 # Fail pipeline on HIGH/CRITICAL ignore-unfixed: true severity: HIGH,CRITICAL - name: Install Cosign uses: sigstore/cosign-installer@v3 - name: Sign image (keyless OIDC) run: | cosign sign \ --yes \ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} # Keyless: uses GitHub OIDC token, no private key needed # Signature stored in Rekor transparency log - name: Verify signature run: | cosign verify \ --certificate-identity-regexp "https://github.com/${{ github.repository }}" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
Test locally before pushing
# Test Kubeconform locally kubeconform -strict -summary k8s/**/*.yaml # Test Conftest locally conftest test k8s/**/*.yaml --policy policy/ --output table # Test against a bad manifest (should fail) cat > /tmp/bad-deployment.yaml << 'EOF' apiVersion: apps/v1 kind: Deployment metadata: name: bad-app spec: replicas: 1 selector: matchLabels: {app: bad-app} template: metadata: labels: {app: bad-app} spec: containers: - name: app image: nginx:latest # violates: no :latest tag # no resources # violates: must have limits # no readinessProbe # violates: must have probe EOF conftest test /tmp/bad-deployment.yaml --policy policy/ # Expected: 3 failures
Add a Trivy SARIF upload for GitHub Security tab
Upload vulnerability results to GitHub's Security tab for a permanent audit trail, even if the pipeline passes.
# Add this step BEFORE the fail-on-HIGH step
- name: Trivy SARIF report (always run)
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
format: sarif
output: trivy-results.sarif
if: always()
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
if: always()Success Criteria
Further Reading
- Kubeconform — github.com/yannh/kubeconform
- Conftest documentation — conftest.dev
- Trivy GitHub Action — github.com/aquasecurity/trivy-action
- Cosign keyless signing — docs.sigstore.dev/signing/overview