CI/CD & GitHub Security
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:
# 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 codeIndirect 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 overMemory hook
pull_request_targetis "run the stranger's code with the house keys." GitHub createdpull_request_targetso 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(notpull_request_target) for untrusted forks - Pin actions to full commit SHA:
uses: actions/checkout@<sha>
Secret Leakage
Environment variables in logs
- 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
on:
workflow_run:
workflows: ["CI"]
types: [completed]workflow_runruns 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:
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-1GCP equivalent
- uses: google-github-actions/auth@v1
with:
workload_identity_provider: projects/123/locations/global/workloadIdentityPools/pool/providers/github
service_account: sa@project.iam.gserviceaccount.comWhy 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 stealMemory hookOIDC 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
mainof 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
| Risk | Description | Mitigation |
|---|---|---|
| Shared self-hosted runners | Leftover state between jobs (env vars, files, git creds) | Use ephemeral runners (new VM per job) |
| Unpinned actions | uses: some-org/action@v1 resolves to latest tag → supply chain risk | Pin to commit SHA |
GITHUB_TOKEN scope | Default token has broad repo permissions | Set permissions: at job level; minimise |
| Cache poisoning | Cached dependencies restored into build; attacker poisons cache entry | Hash lock files; don't cache sensitive artifacts |
| Artifact injection | Artifact uploaded by untrusted job consumed by trusted job | Validate 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
reqeustsinstead ofrequests- Mitigation:
pip-audit,npm audit, lock files + hash verification
SLSA (Supply-chain Levels for Software Artifacts)
| Level | Requirement |
|---|---|
| 1 | Build process documented, provenance exists |
| 2 | Hosted build platform, signed provenance |
| 3 | Hardened build platform, non-falsifiable provenance |
| 4 | Two-party review, hermetic builds |
Tools: Sigstore (cosign, fulcio, rekor) for signing container images and artifacts.
Sigstore Quick Reference
# 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 CISecrets Management
| Approach | Pros | Cons |
|---|---|---|
| GitHub Encrypted Secrets | Built-in, easy | Org-level blast radius; no rotation automation |
| Vault (HashiCorp) | Dynamic secrets, leases, audit log | Operational complexity |
| AWS Secrets Manager / GCP Secret Manager | Native cloud integration, rotation | Vendor lock-in |
| OIDC + cloud IAM | No stored secrets | Requires 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 historyauto-request review from specific teams per file path
add required reviewers before deploying; gate on branch name
SAST / DAST / SCA Integration
| Tool Category | Examples | Integration Point |
|---|---|---|
| SAST (Static) | CodeQL, Semgrep, Checkov | PR check, blocks merge on critical |
| SCA (Dependencies) | Dependabot, Snyk, OWASP Dependency-Check | PR + weekly scan |
| Secrets scanning | truffleHog, gitleaks, GitHub Secret Scanning | Pre-commit hook + PR check |
| Container scanning | Trivy, Grype, Docker Scout | Image build step |
| IaC scanning | Checkov, tfsec, terrascan | Terraform/Helm PR checks |
| DAST | OWASP ZAP, Burp CI | Post-deploy against staging |
Hardened Workflow Template
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.
| License | Type | Key Rules | Use When |
|---|---|---|---|
| MIT | Permissive | Keep copyright notice; no other restrictions | Widely used libraries; commercial-friendly |
| Apache 2.0 | Permissive | Keep notice; patent grant included; state changes | Libraries where patent protection matters; enterprise-safe |
| BSD 2/3-Clause | Permissive | Keep copyright; no endorsement clause | Similar to MIT; common in academia |
| GPL v2/v3 | Copyleft (strong) | Derivatives must also be GPL; distribute source with binary | Tools you want to remain open-source |
| LGPL | Copyleft (weak) | Linking to LGPL library doesn't force GPL on your code | Libraries where you want both open and commercial users |
| AGPL | Copyleft (network) | GPL + if used over a network, must share source | SaaS: forces anyone running a modified version to publish code |
| MPL 2.0 | Weak copyleft (file-level) | Modified files must stay MPL; can combine with proprietary | Balanced option between MIT and GPL |
| CC0 | Public domain | No restrictions at all | Data, documentation |
| Proprietary | Closed | All rights reserved; no redistribution without permission | Commercial software |
Security Implications of License Choice
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.
permissive licenses don't validate the code is safe — a malicious maintainer can still push backdoored code. License permissiveness ≠ trustworthiness.
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.
default copyright law applies; "no license" means no permission to use, copy, or distribute.
use SPDX-License-Identifier: MIT at top of files; machine-readable and used by many compliance tools.
# 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 foundInterview Questions: CI/CD Security
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.
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.
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.
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.
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.
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.
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.