CI/CD is production infrastructure.
No sh*t captain obvious! But most teams still review .py, .ts, .go, and .java files much more than they review the YAML that builds, signs, publishes, and deploys those files.
That gap is where a lot of CI/CD supply-chain security risk lives, and it is a good fit for static analysis because many of the risky patterns are visible before the pipeline runs.
A risky workflow does not need to be a sophisticated zero-day. Sometimes it can be as simple as just:
- a GitHub Action pinned to
@v4instead of a commit SHA - a GitLab
include:that follows a moving branch -
docker:dindwith TLS disabled - a release job restoring cache from less trusted jobs
- jobs that can request OIDC-backed credentials while running repo-controlled build scripts
- untrusted pull request text passed into
eval,bash -c, or a script template
These are the kinds of issues we recently added to Skylos, an open-source local static analysis tool. Skylos already scanned code for security, secrets, dead code, and quality issues. It now also works as a GitHub Actions security scanner and GitLab CI security scanner when you run danger analysis.
This post explains the problem, the checks worth running, and how to scan a repo locally.
Why CI/CD Security Is Different
Application code usually runs after review, after packaging, and inside some controlled runtime.
CI/CD code runs before all of that.
It often has access to:
- repository tokens
- package registry tokens
- cloud credentials
- deployment keys
- signing credentials
- production environment names
- build artifacts
- release permissions
That means your workflow files are not "just config". They are privileged automation code.
The security model is also unusual because CI/CD sits at the boundary between trusted maintainers and untrusted input:
- pull request titles and branch names
- commit messages
- issue comments
- external includes
- third-party actions
- mutable container images
- cache restored from previous jobs
If that boundary is loose, the pipeline can become the path from "someone opened a PR" to "someone got a publish token".
GitHub Actions Issues Worth Checking
GitHub Actions has some well-known footguns.
Dangerous Triggers
pull_request_target is useful, but dangerous. It runs in the context of the base repository and can expose privileged tokens if it checks out or executes untrusted PR code.
Safer default:
on:
pull_request:
If you need pull_request_target, isolate it. Avoid building or running code from the pull request in the privileged job.
Unpinned Actions
This is common:
- uses: actions/checkout@v4
It is stable enough for many teams, but it is still a mutable reference compared with a full commit SHA.
For high-trust release pipelines, prefer:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
The same applies to third-party actions and reusable workflows.
Broad Token Permissions
Avoid relying on default token permissions.
Start with:
permissions: {}
Then grant only what each job needs:
jobs:
test:
permissions:
contents: read
Release jobs may need more, but they should be explicit.
Template Injection
This is risky:
- run: echo "${{ github.event.pull_request.title }}"
Pull request titles are user-controlled. Move the value into an environment variable and quote it like normal shell data:
- run: printf '%s\n' "$PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
OIDC Mixed With Build Scripts
OIDC is good when it removes long-lived cloud secrets.
It becomes risky when the same job also runs repo-controlled build or release scripts:
permissions:
id-token: write
steps:
- run: ./scripts/build-and-publish.sh
Better pattern:
- Build in one job without OIDC.
- Upload a strict artifact.
- Publish from a smaller job that has OIDC and only consumes the artifact.
GitLab CI Issues Worth Checking
GitLab CI has a different syntax and different assumptions, but the same core risk exists: YAML controls privileged automation.
Unpinned External Includes
This is risky:
include:
- project: group/security/pipelines
file: template.yml
Without a pinned ref, the included pipeline can change outside your repository review process.
Better:
include:
- project: group/security/pipelines
file: template.yml
ref: de0fac2e4500dabe0009e67214ff5f5447ce83dd
For remote includes, use integrity checks where possible:
include:
- remote: https://example.com/ci.yml
integrity: sha256-...
Mutable Images and Services
This is common:
image: python:latest
services:
- docker:dind
For release-sensitive jobs, mutable image tags are a supply-chain risk. Use digest-pinned images for jobs that publish, deploy, or handle credentials.
image: python@sha256:...
Docker-in-Docker deserves extra attention because the service is often privileged and connected to build or publish logic.
Docker-in-Docker With TLS Disabled
This GitLab pattern should trigger review:
services:
- docker:dind
variables:
DOCKER_TLS_CERTDIR: ""
DOCKER_HOST: tcp://docker:2375
That means the Docker daemon is exposed without TLS inside the CI network.
If the job also builds and pushes images, a compromised build step can become a path to registry compromise when push credentials are available.
Secrets in YAML Variables
This is a smell:
variables:
DEPLOY_TOKEN: plaintext-token-here
Secret-looking values should live in GitLab protected and masked CI/CD variables, not in .gitlab-ci.yml.
The YAML file should reference controlled values, not contain them.
Untrusted Metadata Passed Into Eval-Like Commands
This is risky:
script:
- eval "$CI_MERGE_REQUEST_TITLE"
Also review commands like:
script:
- bash -c "$CI_COMMIT_MESSAGE"
- node -e "$CI_COMMIT_REF_NAME"
Merge request titles, descriptions, commit messages, and branch names can be attacker-controlled.
Release Jobs Restoring Cache
This is subtle:
deploy:
stage: deploy
cache:
paths:
- node_modules/
script:
- npm publish
Cache is useful for speed, but cache restore in privileged release jobs deserves review when the restored files can be influenced by less trusted jobs.
For publish/deploy jobs, prefer clean installs, immutable artifacts, and narrow permissions over broad cache restore.
How to Scan Locally With Skylos
After the release containing GitLab CI scanning is available:
pip install --upgrade skylos
skylos . --danger
If you are testing from main before release:
pip install "git+https://github.com/duriantaco/skylos.git"
skylos . --danger
Skylos automatically detects the common CI/CD static analysis entry points:
.github/workflows/*.yml.github/workflows/*.yamlaction.ymlaction.yaml.gitlab-ci.yml
No separate flag is needed.
You can also scan a single CI file:
skylos .gitlab-ci.yml --danger
Or run the full local bundle:
skylos . -a
Example: Risky GitLab CI
include:
- project: group/security/pipelines
file: template.yml
image: python:latest
variables:
DEPLOY_TOKEN: plaintext-token-123
DOCKER_TLS_CERTDIR: ""
deploy:
stage: deploy
image: docker:latest
services:
- docker:dind
tags:
- "$RUNNER_TAG"
id_tokens:
VAULT_TOKEN:
aud: https://vault.example.com
cache:
paths:
- node_modules/
script:
- ./scripts/release.sh
- docker push registry.example.com/app:latest
Issues to review:
- unpinned project include
- mutable
python:latest - literal secret-looking variable
- Docker-in-Docker with TLS disabled
- mutable
docker:dind - dynamic runner tag
- OIDC credentials in a job running local release scripts
- cache restore in a release-like job
- missing timeout on a release/OIDC job
Example: Safer Direction
include:
- project: group/security/pipelines
file: template.yml
ref: de0fac2e4500dabe0009e67214ff5f5447ce83dd
image: python@sha256:...
test:
stage: test
script:
- pytest
deploy:
stage: deploy
timeout: 15 minutes
id_tokens:
VAULT_TOKEN:
aud: https://vault.example.com
secrets:
PROD_PASSWORD:
vault: production/password@ops
token: $VAULT_TOKEN
script:
- echo "publish prebuilt artifact"
This is not a complete security model, but it moves in the right direction:
- external CI code is pinned
- privileged jobs have timeouts
- secrets are not hardcoded in YAML
- token selection is explicit
- release logic is smaller
What Static Analysis Can and Cannot Know
Static analysis cannot see everything. And that is just the unfortunate truth.
It cannot know whether your GitLab variable is actually protected. It cannot know whether your runner fleet is isolated correctly. It cannot prove that every release script is safe.
But it can catch patterns that are worth reviewing before the pipeline runs:
- dangerous triggers
- unpinned references
- broad permissions
- literal secrets
- eval-like command sinks
- OIDC exposure to repo-controlled scripts
- release jobs with cache restore
- missing timeouts
That is the right job for a CI/CD static analyzer. Find the risky edges early, keep the signal high, and avoid pretending to know runtime state it cannot inspect.
Official References
These are useful primary docs when reviewing the patterns above:
- GitHub Actions script injection risks: https://docs.github.com/en/actions/concepts/security/script-injections
- GitHub Actions security concepts: https://docs.github.com/en/actions/concepts/security
- GitLab CI YAML reference for
id_tokensandsecrets:token: https://docs.gitlab.com/ee/ci/yaml/ - GitLab CI includes and remote include behavior: https://docs.gitlab.com/ci/yaml/includes/
- GitLab Docker-in-Docker guidance: https://docs.gitlab.com/ci/docker/using_docker_build/
Final Checklist
For GitHub Actions:
- Avoid
pull_request_targetunless isolated. - Pin third-party actions and reusable workflows to full commit SHAs in release-sensitive jobs.
- Set top-level
permissions: {}. - Avoid injecting GitHub context directly into shell scripts.
- Keep OIDC publish jobs small.
- Avoid cache-aware actions in release workflows.
- Add
timeout-minutesto privileged jobs.
For GitLab CI:
- Pin
include:projectrefs to full commit SHAs. - Add integrity checks to remote includes.
- Pin release-sensitive images by digest.
- Avoid disabled-TLS Docker-in-Docker.
- Move secret values out of YAML.
- Do not pass MR/ref metadata into
eval,bash -c, or interpreter-c. - Avoid cache restore in publish/deploy jobs.
- Use static runner tags for privileged jobs.
- Add
timeoutto release, deploy, and OIDC jobs.
If your CI/CD YAML can deploy production, publish packages, or mint cloud credentials, it deserves the same level of review as application code.
Skylos now helps with that review locally:
skylos . --danger
Top comments (0)