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/*.yamlaction.ymlaction.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
These checks are included in the current published release. Upgrade Skylos if your installed version predates them:
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_TOKENpermissions,uses:references, reusable workflows, action metadata, GitHub contexts, andid-token: write. - GitLab CI has
include,image,services,variables,id_tokens,identity,secrets, runnertags,cache, and job-leveltimeout.
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 registry is how Skylos later added Dockerfile, Docker Compose and systemd checks without pretending every config file shares 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_targetandworkflow_runworkflows that deserve privileged-trigger review (SKY-D290)- missing or broad
permissions(SKY-D291) - unpinned actions and reusable workflows, first-party
actions/*included (SKY-D292) actions/checkoutcredentials that stay persisted (SKY-D293)- GitHub context values injected directly into
run:blocks (SKY-D294) - self-hosted runner use in risky workflows (SKY-D295)
- unpinned container images (SKY-D296)
secrets: inheritin reusable workflows (SKY-D297)- overbroad secret access and broad secret environment exposure (SKY-D298, SKY-D309)
- secrets used outside a protected environment (SKY-D299)
- unsafe writes to
GITHUB_ENVorGITHUB_PATH(SKY-D300) - hardcoded container credentials (SKY-D301)
- broad GitHub App tokens (SKY-D302)
- unsound
contains()conditions, spoofable bot checks and unsound multiline conditions (SKY-D303–D305) - insecure workflow commands enabled (SKY-D306)
- workflows or actions without a
name(SKY-D307) - cache-aware actions in release workflows (SKY-D308)
- OIDC token exposure to repo-controlled build or release scripts (SKY-D310)
- artifact uploads that do not fail on missing files (SKY-D311)
- JavaScript install commands that run lifecycle scripts (SKY-D312)
- privileged or release-like jobs without
timeout-minutes(SKY-D313) - risky shell commands in
run:steps, such as piping a remote script to a shell or exfiltrating data (SKY-D327–D329, SKY-D337–D341)
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:
- Build without cloud credentials.
- Upload a strict artifact.
- 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 Skylos GitLab CI scanner reads files named .gitlab-ci.yml instead of scanning arbitrary YAML. Templates pulled in with include: and child-pipeline files with other names are not parsed.
Skylos checks for:
image:orservices:values that are untagged, uselatest, or use unpinned Docker-in-Docker images (SKY-D314)include:projectentries without a full commit SHAref, andinclude:remoteentries withoutintegrity(SKY-D315)- secret-looking literal values in YAML
variables(SKY-D316) - untrusted merge request or ref metadata passed into
eval,sh -c,bash -c,python -c,node -e, and similar sinks (SKY-D317) - Docker-in-Docker services with TLS disabled (SKY-D318)
id_tokensoridentityjobs that run local build or release scripts (SKY-D319)- release/deploy-like jobs that restore cache (SKY-D320)
- release/deploy/OIDC-like jobs without
timeout(SKY-D321) - dynamic runner
tagsusing CI variables (SKY-D322) secrets:entries with multipleid_tokensbut no explicittoken(SKY-D323)
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:latestanddocker:latestare mutable referencesdocker:dindis not digest-pinnedDOCKER_TLS_CERTDIR: ""disables Docker-in-Docker TLSDEPLOY_TOKENlooks 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: <full 40-character commit SHA of the template in group/security/pipelines>
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.
One CI-specific risk is worth naming separately: an AI coding agent that edits the workflow running Skylos, or the [tool.skylos] settings, to turn a red gate green. skylos done --base origin/main reports that as SKY-A114.
Why Jenkins and Terraform are not covered
It is tempting to say "scan all config." That would be the wrong architecture. A Jenkinsfile is Groovy plus plugin behavior, and Terraform has cloud-provider semantics, resource graphs, state and modules. They are not just more YAML-like files.
The registry lets each provider have its own scanner. Besides GitHub Actions (SKY-D290–D313) and GitLab CI (SKY-D314–D323), skylos . --danger now also checks Dockerfiles (remote ADD without a checksum, literal secrets in build values: SKY-D342, SKY-D343), edge Docker Compose files (SKY-D330–D332) and edge systemd units (SKY-D333–D336). Jenkins and Terraform are not covered.
That keeps findings 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
Add it to a merge request pipeline as a GitLab Code Quality report:
skylos-code-quality:
stage: test
image:
name: "$SKYLOS_SCANNER_IMAGE" # registry/path@sha256:… with Skylos 4.47.1 installed
entrypoint: [""]
timeout: 15m
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
script:
- >-
skylos . --danger --secrets --quality --ai-defects --gate --no-upload
--format gitlab -o gl-code-quality-report.json
artifacts:
when: always
reports:
codequality: gl-code-quality-report.json
Run it on the default branch as well, so GitLab can compare the two reports, and don't add --diff or --baseline to this job. The GitLab Code Quality guide has the hardened version of this job and explains how to build the pinned scanner image.
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.
Related Skylos Pages
- GitHub Actions Security Scanner for CI/CD Supply Chain Risk
- Python Security Scanner for GitHub Actions
- How to Secure GitHub Actions for Python Repos
- AI Code Review for Security
- GitHub Actions security in the Skylos docs
- Skylos rules reference
Official References
- GitHub Actions script injection guidance: 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: https://docs.gitlab.com/ci/yaml/
- GitLab CI includes: https://docs.gitlab.com/ci/yaml/includes/
- GitLab Docker-in-Docker guidance: https://docs.gitlab.com/ci/docker/docker_in_docker/
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.