DEV Community

Sour durian
Sour durian

Posted on

GitHub Actions Security and GitLab CI Security: Static Analysis for CI/CD

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 @v4 instead of a commit SHA
  • a GitLab include: that follows a moving branch
  • docker:dind with 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:
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The same applies to third-party actions and reusable workflows.

Broad Token Permissions

Avoid relying on default token permissions.

Start with:

permissions: {}
Enter fullscreen mode Exit fullscreen mode

Then grant only what each job needs:

jobs:
  test:
    permissions:
      contents: read
Enter fullscreen mode Exit fullscreen mode

Release jobs may need more, but they should be explicit.

Template Injection

This is risky:

- run: echo "${{ github.event.pull_request.title }}"
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Better pattern:

  1. Build in one job without OIDC.
  2. Upload a strict artifact.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For remote includes, use integrity checks where possible:

include:
  - remote: https://example.com/ci.yml
    integrity: sha256-...
Enter fullscreen mode Exit fullscreen mode

Mutable Images and Services

This is common:

image: python:latest

services:
  - docker:dind
Enter fullscreen mode Exit fullscreen mode

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:...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Also review commands like:

script:
  - bash -c "$CI_COMMIT_MESSAGE"
  - node -e "$CI_COMMIT_REF_NAME"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If you are testing from main before release:

pip install "git+https://github.com/duriantaco/skylos.git"
skylos . --danger
Enter fullscreen mode Exit fullscreen mode

Skylos automatically detects the common CI/CD static analysis entry points:

  • .github/workflows/*.yml
  • .github/workflows/*.yaml
  • action.yml
  • action.yaml
  • .gitlab-ci.yml

No separate flag is needed.

You can also scan a single CI file:

skylos .gitlab-ci.yml --danger
Enter fullscreen mode Exit fullscreen mode

Or run the full local bundle:

skylos . -a
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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:

Final Checklist

For GitHub Actions:

  • Avoid pull_request_target unless 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-minutes to privileged jobs.

For GitLab CI:

  • Pin include:project refs 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 timeout to 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
Enter fullscreen mode Exit fullscreen mode

GitHub: https://github.com/duriantaco/skylos

Top comments (0)