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

CI/CD YAML is code.

It decides which commands run, which secrets are present, which artifacts get published, and which deployment paths can reach production. In many repositories, the workflow file has more production authority than the application file being reviewed next to it.

That is why Skylos now scans GitHub Actions security and GitLab CI security patterns as part of danger analysis.

The goal is not to turn Skylos into a generic YAML linter. The goal is narrower: find high-signal CI/CD supply-chain risks in known workflow entry points before the pipeline runs.

Skylos now scans:

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

You can run the same command you already use for security findings:

skylos . --danger

If you want the full local bundle:

skylos . -a

If your installed package does not include the latest GitLab CI checks yet, install from the main branch until the next release is available:

pip install "git+https://github.com/duriantaco/skylos.git"
skylos . --danger

After the release containing these checks is available, use the normal install path:

pip install --upgrade skylos
skylos . --danger

What Changed In Skylos

Skylos first added GitHub Actions workflow scanning, then routed config scanners through a registry, then added GitLab CI as a second provider.

That architecture matters.

GitHub Actions and GitLab CI look similar because both use YAML, but their security models are different:

  • GitHub Actions has triggers, GITHUB_TOKEN permissions, uses: references, reusable workflows, action metadata, GitHub contexts, and id-token: write.
  • GitLab CI has include, image, services, variables, id_tokens, identity, secrets, runner tags, cache, and job-level timeout.

The scanner therefore records CI findings with provider-specific metadata instead of treating them as generic config issues.

GitHub Actions findings use:

{
  "kind": "config",
  "domain": "cicd",
  "provider": "github_actions",
  "type": "workflow"
}

GitLab CI findings use:

{
  "kind": "config",
  "domain": "cicd",
  "provider": "gitlab_ci",
  "type": "workflow"
}

That gives Skylos room to add Jenkins, Dockerfile, Terraform, or other config security checks later without pretending they all share the same rule model.

GitHub Actions Security Checks

GitHub Actions workflows are part of the software supply chain because they decide what runs on pull requests, what can write to the repository, and what can publish release artifacts.

Skylos checks for patterns such as:

  • pull_request_target and workflow_run workflows that deserve privileged-trigger review
  • missing or broad permissions
  • unpinned third-party actions and reusable workflows
  • actions/checkout credentials that stay persisted
  • GitHub context values injected directly into run: blocks
  • self-hosted runner use in risky workflows
  • unpinned container images
  • secrets: inherit in reusable workflows
  • overbroad secret access patterns
  • unsafe writes to GITHUB_ENV or GITHUB_PATH
  • broad GitHub App tokens
  • cache-aware actions in release workflows
  • OIDC token exposure to repo-controlled build or release scripts
  • artifact uploads that do not fail on missing files
  • JavaScript install commands that run lifecycle scripts
  • privileged or release-like jobs without timeout-minutes

One example is direct context injection:

steps:
  - run: echo "${{ github.event.pull_request.title }}"

Pull request titles are user-controlled. GitHub's own security guidance warns that contexts can contain attacker-controlled data. A safer shape is to move the value into an environment variable and handle it like shell data:

steps:
  - run: printf '%s\n' "$PR_TITLE"
    env:
      PR_TITLE: ${{ github.event.pull_request.title }}

Another example is a privileged release job that can request OIDC credentials while running repo-controlled scripts:

permissions:
  contents: read
  id-token: write

steps:
  - run: ./scripts/build-and-publish.sh

OIDC is usually better than long-lived cloud secrets. The risk is mixing credential issuance with a broad build script in the same job. A cleaner pattern is:

  1. Build without cloud credentials.
  2. Upload a strict artifact.
  3. Publish from a smaller job that consumes the artifact and has only the credentials it needs.

GitLab CI Security Checks

GitLab CI has its own workflow security patterns. The first Skylos GitLab CI scanner intentionally focuses on .gitlab-ci.yml instead of scanning arbitrary YAML.

Skylos checks for:

  • image: or services: values that are untagged, use latest, or use unpinned Docker-in-Docker images
  • include:project entries without a full commit SHA ref
  • include:remote entries without integrity
  • secret-looking literal values in YAML variables
  • untrusted merge request or ref metadata passed into eval, sh -c, bash -c, python -c, node -e, and similar sinks
  • Docker-in-Docker services with TLS disabled
  • id_tokens or identity jobs that run local build or release scripts
  • release/deploy-like jobs that restore cache
  • release/deploy/OIDC-like jobs without timeout
  • dynamic runner tags using CI variables
  • secrets: entries with multiple id_tokens but no explicit token

Here is a compact risky example:

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

There are several separate review points in that file:

  • the external project include is not pinned to a full commit SHA
  • python:latest and docker:latest are mutable references
  • docker:dind is not digest-pinned
  • DOCKER_TLS_CERTDIR: "" disables Docker-in-Docker TLS
  • DEPLOY_TOKEN looks like a literal secret committed in YAML
  • the runner tag is dynamic
  • the job can request OIDC-backed credentials and runs a repo-controlled release script
  • the release-like job restores cache
  • the release/OIDC job has no timeout

None of those require executing the pipeline to detect. That is why they are good candidates for static analysis.

What A Safer GitLab CI Shape Looks Like

This is not a universal template, but it moves the trust boundaries in the right 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"

The important changes are:

  • external CI code is pinned
  • the image is digest-pinned
  • secrets are not committed as YAML values
  • token selection is explicit
  • the privileged job has a timeout
  • the publish job is smaller than the build job

You still need GitLab project settings, protected variables, protected environments, and runner isolation. Skylos does not claim to see those. It only flags risky repository-visible patterns.

What Static Analysis Can And Cannot Know

Static analysis is strongest when it stays honest about its inputs.

Skylos can see:

  • workflow files
  • action metadata
  • GitLab CI YAML
  • scripts referenced in repository code when scanning the full repo
  • static strings, keys, commands, includes, permissions, cache settings, and timeout settings

Skylos cannot see:

  • whether a GitLab variable is protected or masked
  • how your self-hosted runner fleet is isolated
  • whether a GitHub environment requires approval
  • whether a Docker daemon is reachable from another job at runtime
  • whether a cloud role trust policy is correctly scoped outside the repo

That is why the CI/CD scanner is conservative. It does not flag every string that looks unusual. It looks for high-risk combinations that are visible before merge.

Why We Did Not Add Jenkins, Docker, Or Terraform In This Same Pass

It is tempting to say "scan all config."

That would be the wrong architecture.

Jenkinsfile is Groovy plus plugin behavior. Dockerfile has image build and runtime semantics. Terraform has cloud provider semantics, resource graphs, state, modules, and policy concerns. They are not just more YAML-like files.

The registry added in Skylos lets each provider have its own scanner:

  • GitHub Actions scanner for GitHub workflow semantics
  • GitLab CI scanner for GitLab pipeline semantics
  • future Jenkins scanner for Jenkins semantics
  • future Dockerfile scanner for container build semantics
  • future Terraform scanner for infrastructure semantics

That keeps findings more accurate and avoids one giant generic config bucket.

How To Use It

Run danger analysis:

skylos . --danger

Run the full local suite:

skylos . -a

Scan one GitLab CI file:

skylos .gitlab-ci.yml --danger

Scan one repository and emit JSON:

skylos . --danger --json

If you are already using Skylos in CI, the workflow findings show up in the same danger results as code security findings.

Official References

The Practical Rule

If CI/CD YAML can publish packages, mint credentials, deploy production, or move artifacts into a release path, it deserves the same review discipline as application code.

Static analysis will not replace that review.

It can make sure the obvious risky edges are visible before the workflow runs.