Learn how to build and harden CI/CD pipelines - covering OIDC federation, Vault secrets injection, least privilege runners, GitHub Actions permissions, Jenkins hardening, artifact signing with Cosign, and audit logging.
Your CI/CD pipeline is the most powerful system in your engineering organisation. It has access to your source code, your deployment credentials, your production environments, and your entire software supply chain. When you run a build, the pipeline can read secrets, write to databases, push to registries, and deploy to production — all automatically. This power makes pipelines a high-value target. A compromised pipeline does not just affect one developer's machine. It can inject malicious code into every artifact your company ships, extract every secret in your environment, and give an attacker persistent access to production systems. The SolarWinds breach — one of the largest supply chain attacks in history — happened because attackers compromised the build system. They injected malicious code into the build process, which then made its way into signed software updates delivered to 18,000 organisations. The attack was invisible for months because the compromised code came out of a legitimate, trusted build pipeline. This is why CI/CD security is not just about adding a security scanner to your pipeline — it is about hardening the pipeline itself so that even if credentials are stolen or a step is compromised, the blast radius is limited and the attack is detectable. ---
Before hardening anything, understand what an attacker actually targets in a CI/CD pipeline. ### Where Pipelines Get Compromised ``` Attack vector 1: Stolen pipeline credentials A CI/CD pipeline needs AWS credentials to deploy Developer stores AWS access key in GitHub Secrets Attacker finds the key in pipeline logs or through a vulnerability Attacker now has AWS access with whatever permissions the key has Attack vector 2: Malicious code injection via pull request Open-source project accepts external contributions Attacker opens a PR that adds a step to print all environment variables Pipeline runs the PR code and prints secrets to logs Attacker reads the CI logs and extracts credentials Attack vector 3: Dependency confusion / poisoned actions Pipeline uses a GitHub Action pinned to a tag: uses: org/action@v2 Attacker compromises the action repository and pushes malicious code to the v2 tag Next pipeline run executes the malicious code with full pipeline permissions Credentials, secrets, and repository access are all compromised Attack vector 4: Overprivileged runners Build agent runs as root or with admin permissions Compromised build job can read other jobs' files, tokens, or environment variables Single compromised job gives attacker access to everything that runner can reach Attack vector 5: Exposed secrets in logs Pipeline logs to a shared logging system A debug step accidentally prints environment variables Logs are accessible to all developers — secret is now exposed ``` ### The Principle That Fixes Most of These Almost every pipeline security failure comes down to the same root cause: **too much trust in the wrong place**. The fix is the same principle from the DevSecOps Foundations module: least privilege and Zero Trust applied specifically to pipelines. Every pipeline job should have exactly the permissions it needs — no more. Credentials should be short-lived and scoped to the specific job. Every action should be logged and auditable. ---
The most impactful single change you can make to GitHub Actions security is eliminating stored AWS credentials entirely. Instead of storing a static access key in GitHub Secrets and hoping it never leaks, OIDC federation lets your pipeline request short-lived credentials directly from AWS at runtime. ### How OIDC Federation Works ``` Traditional approach: Developer generates AWS access key Stores key in GitHub Secrets Pipeline reads key from secrets and sets as environment variable Key is valid for years, can be used from anywhere If leaked — attacker has persistent AWS access OIDC approach: No keys stored anywhere When pipeline runs, GitHub generates a signed JWT token JWT contains: which repository, which branch, which workflow Pipeline presents JWT to AWS AWS verifies the JWT is signed by GitHub AWS issues a short-lived credential (valid 1 hour, scoped to allowed actions) Pipeline uses credential, it expires when the job ends If intercepted — credential is already expired before anyone can use it ``` ### Step 1 — Add the Identity Provider to AWS Do this once in your AWS account: ```bash # Using Terraform (recommended) resource "aws_iam_openid_connect_provider" "github" { url = "https://token.actions.githubusercontent.com" client_id_list = ["sts.amazonaws.com"] # GitHub's OIDC thumbprint thumbprint_list = ["a031c46782e6e6c662c2c87c76da9aa62ccabd8e"] } ``` Or in the AWS console: IAM → Identity Providers → Add provider → OpenID Connect: * Provider URL: `https://token.actions.githubusercontent.com` * Audience: `sts.amazonaws.com` ### Step 2 — Create an IAM Role That GitHub Can Assume ```bash # Using Terraform data "aws_iam_policy_document" "github_assume_role" { statement { effect = "Allow" actions = ["sts:AssumeRoleWithWebIdentity"] principals { type = "Federated" identifiers = [aws_iam_openid_connect_provider.github.arn] } condition { test = "StringLike" variable = "token.actions.githubusercontent.com:sub" # Only allow the specific repository and branch you trust values = ["repo:your-org/your-repo:ref:refs/heads/main"] } condition { test = "StringEquals" variable = "token.actions.githubusercontent.com:aud" values = ["sts.amazonaws.com"] } } } resource "aws_iam_role" "github_deploy" { name = "GitHubActionsDeployRole" assume_role_policy = data.aws_iam_policy_document.github_assume_role.json } # Attach only the permissions this role actually needs resource "aws_iam_role_policy_attachment" "github_deploy_ecr" { role = aws_iam_role.github_deploy.name policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryFullAccess" } ``` The `StringLike` condition on the subject claim is critical — it ensures only your specific repository can assume this role, not any GitHub Actions workflow from any repository. ### Step 3 — Use OIDC in Your GitHub Actions Workflow ```yaml name: Deploy to AWS on: push: branches: [main] # Required — allows the workflow to request an OIDC token permissions: id-token: write contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # No AWS keys stored anywhere — OIDC requests credentials at runtime - name: Configure AWS credentials via OIDC uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole aws-region: ap-south-1 role-session-name: GitHubActions-${{ github.run_id }} # Now you can use AWS CLI — credentials were just issued and expire in 1 hour - name: Deploy to ECS run: | aws ecs update-service \ --cluster production \ --service my-app \ --force-new-deployment ``` ### Restricting OIDC to Specific Branches You can restrict OIDC federation to only allow production deployments from your main branch: ```yaml # In the IAM role trust policy condition: condition { test = "StringLike" variable = "token.actions.githubusercontent.com:sub" # Only main branch can deploy to production values = ["repo:your-org/your-repo:ref:refs/heads/main"] } # A separate role for development deployments condition { test = "StringLike" variable = "token.actions.githubusercontent.com:sub" # Any branch can deploy to staging values = ["repo:your-org/your-repo:*"] } ``` ---
For secrets that are not cloud credentials — database passwords, API keys, TLS certificates — HashiCorp Vault is the industry standard. Instead of storing these in GitHub Secrets (where they are static and difficult to audit), Vault issues them dynamically at runtime. ### Why Vault Instead of GitHub Secrets ``` GitHub Secrets limitations: Static — the same value is used every time until manually rotated No audit log — you cannot see which workflow accessed a secret when No expiry — secrets stay valid until someone remembers to rotate them Shared — all jobs in a repository can access the same secret Vault advantages: Dynamic — can generate unique credentials per job that expire automatically Full audit log — every secret access is recorded with timestamp and identity Policy-based — define exactly which workflows can access which secrets Rotation — rotate credentials automatically without touching pipeline configs ``` ### Using the Vault GitHub Actions Plugin ```yaml name: Deploy with Vault Secrets on: push: branches: [main] permissions: id-token: write # For OIDC authentication to Vault contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # Authenticate to Vault using OIDC (no Vault token stored anywhere) - name: Import Secrets from Vault uses: hashicorp/vault-action@v3 with: url: https://vault.your-company.com method: jwt role: github-deploy-role secrets: | secret/data/production/database password | DB_PASSWORD ; secret/data/production/stripe secret_key | STRIPE_SECRET_KEY ; secret/data/production/redis connection_string | REDIS_URL # Vault secrets are now available as environment variables # They were never stored in GitHub — fetched fresh each run - name: Run application tests run: | DATABASE_URL="postgresql://appuser:${DB_PASSWORD}@db:5432/myapp" \ pytest tests/ ``` ### Dynamic Database Credentials — The Gold Standard The most powerful Vault feature for database security is dynamic credentials. Instead of a static password, Vault creates a temporary username and password for each job: ```bash # Configure Vault's database secrets engine vault secrets enable database vault write database/config/my-database \ plugin_name=postgresql-database-plugin \ allowed_roles="deploy-role" \ connection_url="postgresql://vault:{{password}}@db:5432/myapp" \ username="vault" \ password="vault-management-password" vault write database/roles/deploy-role \ db_name=my-database \ creation_statements="CREATE ROLE '{{name}}' WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA public TO '{{name}}';" \ default_ttl="1h" \ max_ttl="24h" ``` Now when a deployment job runs, Vault creates a fresh database user with a unique password that automatically expires after 1 hour. After the job finishes, the credentials are gone. No long-lived database passwords anywhere. ---
GitHub Actions pipelines come with a default `GITHUB_TOKEN` that can read and write to the repository. Many pipelines use this token for things like pushing container images or creating releases. Without proper scoping, a compromised job could use this token to modify any part of the repository. ### Set Minimal Permissions at the Workflow Level ```yaml name: Build and Test on: [push, pull_request] # Deny everything by default — explicitly grant only what is needed permissions: contents: read # Read repository code only jobs: test: runs-on: ubuntu-latest # Override permissions at job level for specific needs permissions: contents: read checks: write # Allow writing test result annotations steps: - uses: actions/checkout@v4 - run: npm test ``` ### Reference Actions by Commit SHA, Not Tag ```yaml # Dangerous — attacker can push malicious code to the v2 tag - uses: actions/checkout@v2 # Safe — SHA is immutable, even if the tag moves, this step never changes - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 ``` This is the supply chain attack vector that GitHub's SHA pinning enforcement is designed to prevent. When you pin by SHA: * The action cannot be silently updated to include malicious code * Dependabot will still notify you of new versions * You choose when to update, after reviewing what changed ### Enable SHA Pinning Enforcement Across the Organisation In your GitHub organisation settings, under Actions → General, enable: * "Require SHA pinning for all actions" This blocks any workflow that references an action by tag or branch instead of a full commit SHA. ### Prevent Secrets From Leaking in Pull Request Workflows ```yaml # Dangerous — runs with write access on external PR code on: pull_request: # Safe — external PRs get read-only access, no secrets on: pull_request_target: types: [opened, synchronize] jobs: pr-check: permissions: contents: read pull-requests: write # Only write permission is for PR comments steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false # Never persist tokens in checkout ``` External pull requests from forks should never have access to production secrets. Use `pull_request_target` with explicit, minimal permissions to safely run checks on external contributions. ---
Jenkins is widely used in Indian product companies and enterprises. Its flexibility makes it powerful but also makes security configuration critical. A misconfigured Jenkins instance is one of the most common entry points for CI/CD compromise. ### Critical Jenkins Security Settings ``` Navigate to: Manage Jenkins → Security 1. Enable security (if not already enabled) Authentication: Use LDAP, SSO, or SAML — not Jenkins own user database 2. Authorization: Matrix-based security Do NOT use "Anyone can do anything" or "Logged-in users can do anything" Define explicit permissions per user or group 3. CSRF Protection: Enable the default crumb issuer Prevents cross-site request forgery attacks against Jenkins UI 4. Agents → Controller Security: Enable Prevents build agents from executing dangerous commands on the controller 5. Build number of executors on built-in node: Set to 0 Never run builds on the Jenkins controller itself Controller compromise should not affect build security ``` ### Secure Credentials Management in Jenkins ```groovy // WRONG — credential hardcoded in pipeline script pipeline { agent any stages { stage('Deploy') { steps { sh 'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE ./deploy.sh' } } } } // CORRECT — credential stored in Jenkins Credentials, never hardcoded pipeline { agent any stages { stage('Deploy') { steps { withCredentials([ string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), string(credentialsId: 'aws-secret-key', variable: 'AWS_SECRET_ACCESS_KEY') ]) { sh './deploy.sh' } } } } } ``` ### Mask Secrets in Jenkins Logs Install the Mask Passwords plugin and configure it to automatically mask common secret patterns: ```yaml # JCasC configuration unclassified: globalMaskPasswordsConfig: maskPasswordsParamDefNames: - "password" - "secret" - "api_key" - "token" - "access_key" ``` ### Scope Credentials to Folders Rather than making all credentials available to all jobs, use Jenkins folders to scope credentials: ``` Jenkins Organisation ├── Production/ (Folder with production credentials) │ credentials: prod-aws-key, prod-db-password │ ├── deploy-app-a/ (Can access production credentials) │ └── deploy-app-b/ (Can access production credentials) │ └── Development/ (Folder with development credentials) credentials: dev-aws-key, dev-db-password ├── build-app-a/ (Cannot access production credentials) └── test-app-b/ (Cannot access production credentials) ``` A compromised development build job cannot access production credentials because they are scoped to a different folder. ### Enable Pipeline Sandbox The Groovy sandbox prevents pipeline scripts from accessing Jenkins internals or running arbitrary system commands: ``` Manage Jenkins → In-process Script Approval Enable: Groovy sandbox for pipeline scripts Only approved methods and constructors can be called Unapproved scripts require administrator approval before running ``` ### Jenkins + Vault Integration For the most secure Jenkins credential management, use the HashiCorp Vault plugin to fetch secrets at runtime instead of storing them in Jenkins: ```groovy pipeline { agent any stages { stage('Deploy') { steps { withVault([ configuration: [ vaultUrl: 'https://vault.company.com', vaultCredentialId: 'vault-approle-token' ], vaultSecrets: [[ path: 'secret/production/database', secretValues: [ [envVar: 'DB_PASSWORD', vaultKey: 'password'] ] ]] ]) { sh './deploy.sh' } } } } } ``` ---
Your CI/CD pipeline is the most powerful system in your engineering organisation. It has access to your source code, you...
Before hardening anything, understand what an attacker actually targets in a CI/CD pipeline. Where Pipelines Get Comprom...
The most impactful single change you can make to GitHub Actions security is eliminating stored AWS credentials entirely....
For secrets that are not cloud credentials — database passwords, API keys, TLS certificates — HashiCorp Vault is the ind...
GitHub Actions pipelines come with a default GITHUBTOKEN that can read and write to the repository. Many pipelines use t...
Jenkins is widely used in Indian product companies and enterprises. Its flexibility makes it powerful but also makes sec...
Once you have built an artifact — a Docker image, a binary, a release package — you need a way to prove that it was buil...
Putting all these controls together, here is what a production-grade secure CI/CD pipeline looks like in GitHub Actions:...
You cannot investigate a compromise if you have no record of what happened. Every CI/CD platform generates audit events ...
This lab builds a complete secure pipeline for a sample application. You will implement OIDC federation, secret scanning...
Aligns directly with DevOps, Site Reliability (SRE), and Platform Engineering job descriptions.