Security Notes
CI/CD & Supply Chain

CI/CD & GitHub Security

12 min read 8 sections 7 model answers

GitHub Actions Attack Surface

Poisoned Pipeline Execution (PPE)

An attacker with write access to a PR-triggering branch can modify workflow YAML or scripts that the pipeline executes.

Direct PPE — attacker modifies the workflow file itself:

yaml
# Vulnerable: runs on pull_request_target with checkout of PR head
on: pull_request_target
jobs:
  build:
    steps:
      - uses: actions/checkout@v3
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # ← attacker controls this
      - run: ./build.sh                                   # ← runs attacker code

Indirect PPE — attacker modifies a script/Makefile that the workflow calls, without touching the workflow YAML.

   pull_request  (SAFE for forks)          pull_request_target  (DANGEROUS for forks)
   ─────────────────────────────           ──────────────────────────────────────────
   Runs in the FORK's context              Runs in the BASE repo's context
   • NO access to secrets                  • FULL access to secrets
   • read-only GITHUB_TOKEN                • write GITHUB_TOKEN
   • checks out attacker code  ✔ fine      • checks out BASE branch by default
     (it has nothing to steal)               ...but devs often add:
                                              with: ref: ${{ pull_request.head.sha }}
                                              → checks out ATTACKER code
                                              → runs it WITH secrets  ✗ game over
Memory hook

pull_request_target is "run the stranger's code with the house keys." GitHub created pull_request_target so fork PRs could do things needing secrets (label, comment) — but it runs with the base repo's secrets and write token. The fatal pattern is then explicitly checking out the PR's head SHA (attacker code) and building/testing it — now untrusted code executes with full secrets. Mnemonic: pull_request = sandbox the stranger (no secrets); pull_request_target = trust the repo, NOT the PR's code. If you must use it, never check out and run fork code in that context, and gate on first-time-contributor approval.

Mitigation

  • Never check out untrusted code and then execute it in a privileged context
  • Use pull_request (not pull_request_target) for untrusted forks
  • Pin actions to full commit SHA: uses: actions/checkout@<sha>

Secret Leakage

Environment variables in logs

yaml
- run: echo "Token is ${{ secrets.MY_TOKEN }}"  # ← masked but risky pattern
  • GitHub masks registered secrets in logs, but base64-encoding or splitting leaks them

workflow_run privilege escalation

yaml
on:
  workflow_run:
    workflows: ["CI"]
    types: [completed]
  • workflow_run runs in the context of the base branch (has secrets), but can access artifacts from the untrusted PR — classic confused deputy

Mitigation

  • Audit who can approve workflow runs from forks
  • Use short-lived OIDC tokens instead of long-lived secrets
  • Scope secrets to environments with required reviewers

OIDC — Replacing Long-Lived Credentials

GitHub Actions can request a short-lived OIDC token, which cloud providers (AWS, GCP, Azure) trust directly:

yaml
permissions:
  id-token: write
  contents: read

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v2
  with:
    role-to-assume: arn:aws:iam::123456789:role/github-actions-role
    aws-region: us-east-1

GCP equivalent

yaml
- uses: google-github-actions/auth@v1
  with:
    workload_identity_provider: projects/123/locations/global/workloadIdentityPools/pool/providers/github
    service_account: sa@project.iam.gserviceaccount.com

Why this mattersno stored credentials → no credential theft from secrets store; token is bound to repo + branch + job.

   OLD WAY (stored secret)                  OIDC WAY (short-lived, no stored secret)
   ───────────────────────                  ────────────────────────────────────────
   Long-lived AWS key in GH Secrets         GH mints a signed OIDC token per job
        │ (sits there forever, leakable)         │ (expires in minutes, scoped to repo+branch)
        ▼                                         ▼
   Workflow uses it                          Cloud verifies the token's claims against a
        │                                    trust policy (this repo, this branch only)
        ▼                                         │
   Leak = attacker has the key               ▼
   until someone rotates it                  Cloud returns SHORT-LIVED creds → nothing to steal
Memory hook

OIDC turns "a secret you store" into "an identity you prove." Instead of stashing a long-lived cloud key in GitHub Secrets (which can leak and lives forever), the runner asks GitHub to mint a signed, short-lived OIDC token describing who's asking — this repo, this branch, this workflow. The cloud's trust policy checks those claims and hands back temporary credentials. There's no standing secret to steal, and you can scope the trust so only main of one repo can assume a prod role. Mnemonic: OIDC = prove who you are, don't carry a key. It's the CI/CD twin of "roles over users" — the single biggest CI credential-hygiene upgrade.


Runner Security

RiskDescriptionMitigation
Shared self-hosted runnersLeftover state between jobs (env vars, files, git creds)Use ephemeral runners (new VM per job)
Unpinned actionsuses: some-org/action@v1 resolves to latest tag → supply chain riskPin to commit SHA
GITHUB_TOKEN scopeDefault token has broad repo permissionsSet permissions: at job level; minimise
Cache poisoningCached dependencies restored into build; attacker poisons cache entryHash lock files; don't cache sensitive artifacts
Artifact injectionArtifact uploaded by untrusted job consumed by trusted jobValidate artifact provenance (SLSA)

Supply Chain Security

Dependency Confusion

  • Attacker publishes a public package with the same name as an internal private package
  • Package manager resolves public package at higher version number
  • Mitigation: pin private feeds, use namespace scoping, verify package hashes

Typosquatting

  • reqeusts instead of requests
  • Mitigation: pip-audit, npm audit, lock files + hash verification

SLSA (Supply-chain Levels for Software Artifacts)

LevelRequirement
1Build process documented, provenance exists
2Hosted build platform, signed provenance
3Hardened build platform, non-falsifiable provenance
4Two-party review, hermetic builds

Tools: Sigstore (cosign, fulcio, rekor) for signing container images and artifacts.

Sigstore Quick Reference

bash
# Sign an image
cosign sign --key cosign.key ghcr.io/org/image:tag

# Verify
cosign verify --key cosign.pub ghcr.io/org/image:tag

# Keyless (OIDC-based)
cosign sign ghcr.io/org/image:tag   # uses OIDC identity from CI

Secrets Management

ApproachProsCons
GitHub Encrypted SecretsBuilt-in, easyOrg-level blast radius; no rotation automation
Vault (HashiCorp)Dynamic secrets, leases, audit logOperational complexity
AWS Secrets Manager / GCP Secret ManagerNative cloud integration, rotationVendor lock-in
OIDC + cloud IAMNo stored secretsRequires OIDC support at cloud provider

Rotation — detect stale credentials with tools like truffleHog, gitleaks, detect-secrets.


Branch Protection & Access Control

Settings → Branches → Branch protection rules:
  ✔ Require pull request reviews before merging
  ✔ Require status checks to pass
  ✔ Require signed commits (GPG/SSH)
  ✔ Restrict who can push to matching branches
  ✔ Require linear history
CODEOWNERS

auto-request review from specific teams per file path

Environments

add required reviewers before deploying; gate on branch name


SAST / DAST / SCA Integration

Tool CategoryExamplesIntegration Point
SAST (Static)CodeQL, Semgrep, CheckovPR check, blocks merge on critical
SCA (Dependencies)Dependabot, Snyk, OWASP Dependency-CheckPR + weekly scan
Secrets scanningtruffleHog, gitleaks, GitHub Secret ScanningPre-commit hook + PR check
Container scanningTrivy, Grype, Docker ScoutImage build step
IaC scanningCheckov, tfsec, terrascanTerraform/Helm PR checks
DASTOWASP ZAP, Burp CIPost-deploy against staging

Hardened Workflow Template

yaml
name: Secure CI

on:
  pull_request:
    branches: [main]

permissions:
  contents: read         # minimise default GITHUB_TOKEN scope

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write    # only if OIDC needed
    steps:
      - uses: actions/checkout@<commit-sha>   # pinned
      - name: Run SAST
        uses: github/codeql-action/analyze@<sha>
      - name: Scan dependencies
        run: pip-audit -r requirements.txt
      - name: Scan secrets
        uses: trufflesecurity/trufflehog@<sha>
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}

Software Licenses

Understanding licenses matters when evaluating open-source dependencies or choosing a license for your own project.

LicenseTypeKey RulesUse When
MITPermissiveKeep copyright notice; no other restrictionsWidely used libraries; commercial-friendly
Apache 2.0PermissiveKeep notice; patent grant included; state changesLibraries where patent protection matters; enterprise-safe
BSD 2/3-ClausePermissiveKeep copyright; no endorsement clauseSimilar to MIT; common in academia
GPL v2/v3Copyleft (strong)Derivatives must also be GPL; distribute source with binaryTools you want to remain open-source
LGPLCopyleft (weak)Linking to LGPL library doesn't force GPL on your codeLibraries where you want both open and commercial users
AGPLCopyleft (network)GPL + if used over a network, must share sourceSaaS: forces anyone running a modified version to publish code
MPL 2.0Weak copyleft (file-level)Modified files must stay MPL; can combine with proprietaryBalanced option between MIT and GPL
CC0Public domainNo restrictions at allData, documentation
ProprietaryClosedAll rights reserved; no redistribution without permissionCommercial software

Security Implications of License Choice

GPL/AGPL in a dependency

if your product is distributed or used as a SaaS, you may be legally required to open-source your entire codebase. Run license scanners (e.g., FOSSA, licensee, license-checker) in CI to catch accidental GPL imports.

MIT/Apache 2.0 supply chain risk

permissive licenses don't validate the code is safe — a malicious maintainer can still push backdoored code. License permissiveness ≠ trustworthiness.

Apache 2.0 vs MIT for patents

Apache 2.0 includes an explicit patent license grant and a termination clause — if you sue contributors for patent infringement, your Apache license terminates. MIT has no such protection.

Unlicensed code is NOT free to use

default copyright law applies; "no license" means no permission to use, copy, or distribute.

SPDX identifiers

use SPDX-License-Identifier: MIT at top of files; machine-readable and used by many compliance tools.

bash
# Audit licenses in Python project
pip-licenses --with-system --format=markdown

# Audit npm project
npx license-checker --summary

# FOSSA CLI (comprehensive, used in CI)
fossa analyze
fossa test  # fails build if unapproved licenses found

Interview Questions: CI/CD Security

Q
What's the difference between pull_request and pull_request_target, and why does it matter for security?
Model answer

pull_request runs the workflow in the fork's context with no access to the base repo's secrets and a read-only token, so even though it checks out attacker-supplied code, there's nothing valuable to steal — it's safe for untrusted forks. pull_request_target runs in the base repo's context with full secrets and a write token; it was designed so fork PRs could do privileged things like labeling. The danger is that it checks out the base branch by default, but developers often add an explicit checkout of the PR head SHA and then build or test it — now untrusted code runs with full secrets and write access, which is a complete compromise of the repo and anything those secrets reach. So the rule is: use pull_request for untrusted forks, and if you must use pull_request_target, never check out and execute the PR's code in that context.

Q
How does OIDC eliminate the need for stored cloud credentials in CI?
Model answer

Instead of storing a long-lived cloud key in GitHub Secrets, the runner requests a short-lived, signed OIDC token from GitHub that asserts who's running — the repository, branch, and workflow. The cloud provider has a trust policy that verifies those claims and, if they match, returns temporary scoped credentials. So there's no standing secret sitting in the secrets store to leak, the credentials expire in minutes, and you can scope the trust tightly — for example only the main branch of one specific repo may assume the production role. It's the CI/CD equivalent of using roles instead of long-lived users: you prove an identity rather than carrying a key, which removes the single most common CI credential-theft vector.

Q
Explain Poisoned Pipeline Execution, direct versus indirect.
Model answer

PPE is getting the CI runner to execute attacker-controlled code so it runs with the pipeline's privileges — secrets, cloud access, signing keys. Direct PPE is modifying the pipeline definition itself, the workflow YAML, for example adding a step that exfiltrates secrets, which works if the attacker can influence a branch that triggers the pipeline. Indirect PPE is modifying something the pipeline calls rather than the workflow file — a build script, Makefile, test config, or an npm lifecycle hook — so even a protected workflow file executes poisoned code. Indirect is sneakier because reviewers scrutinize the workflow YAML but overlook the scripts it invokes. The fork-based public variant abuses pull_request_target to run a fork's changes with the upstream repo's secrets.

Q
What is dependency confusion and how do you prevent it?
Model answer

Dependency confusion exploits how package managers resolve a name that exists in both a private internal registry and the public one. If a company uses an internal package and an attacker publishes a public package with the same name at a higher version, the resolver may pull the attacker's public package because it favors the highest version across configured sources — running their install-time code in the build. Prevention: scope or namespace internal packages so their names can't be claimed publicly, configure the resolver to only get internal names from the private registry, pin dependencies with hashes so an unexpected package is rejected, and claim your internal names defensively on the public registry. It was the basis of a famous 2021 research bug-bounty campaign that breached many major companies.

Q
How would you detect a supply-chain compromise in your build pipeline?
Model answer

I'd instrument both the control plane and the build runtime. Control plane: alert on changes to workflow files, self-hosted runner config, branch protection, and any action whose pinned SHA changed, plus new collaborators or deploy keys. Runtime: monitor what builds actually do — unexpected outbound network connections during dependency install or build, processes spawning shells, and access to credentials a step shouldn't need — since malicious packages and poisoned steps reveal themselves by behavior. I'd also watch for new public packages matching our internal namespace, verify artifact provenance with SLSA attestations and signature checks so a tampered artifact is caught, and feed CI/CD audit logs into the SIEM. The premise is that attackers rely on CI/CD being a logging blind spot, so visibility into both who-changed-what and what-the-build-did is the detection.

Q
Walk me through a SLSA level 3 build setup.
Model answer

SLSA is about provenance — proving how an artifact was built. Level 3 requires a hardened, hosted build platform and non-falsifiable, signed provenance. In practice that means builds run on a managed, isolated builder, not a developer's machine, where the build can't tamper with its own provenance; each build produces a signed attestation describing the source commit, the builder identity, and the build steps; and the signing uses a mechanism the build itself can't forge — for instance an ephemeral OIDC identity through Sigstore, with the signature logged in a transparency log like Rekor. Then consumers verify that provenance before deploying, enforced at admission via cosign and a policy engine or Binary Authorization. The result is you can cryptographically confirm an artifact came from your trusted pipeline off a specific commit and wasn't built or altered elsewhere.

Q
What's the blast radius of a compromised GITHUB_TOKEN?
Model answer

It depends on the token's permissions and lifetime. The default GITHUB_TOKEN is automatically scoped per workflow and expires when the job ends, which limits exposure — but if it has broad permissions, within that window an attacker can push code, modify or create releases and packages, alter workflows to plant persistence, open or approve PRs, and touch anything in the repo it has write to, potentially poisoning artifacts that downstream consumers trust. If the workflow over-grants permissions, or worse if it's a long-lived personal access token or app token rather than the ephemeral GITHUB_TOKEN, the blast radius and duration grow dramatically. So the mitigations are setting least-privilege permissions at the job level, never echoing the token, and preferring the short-lived default token over PATs. The supply-chain angle is what makes it severe: write access to a build pipeline can ship malware with a trusted signature.