← Back to Guide
GitOps & CI/CD L2 · PRACTICAL ~60 min

Secure CI Pipeline with Kubeconform, OPA, and Cosign

Build a GitHub Actions pipeline that validates Kubernetes manifests with Kubeconform, enforces OPA Conftest policies, scans images with Trivy failing on HIGH/CRITICAL, and signs the output image with Cosign — all before any deployment.

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

Steps

01

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"
}
02

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 }}
03

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
04

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