Run es-python in CI

How to run your test suite under es-python in a CI pipeline and fail the build when the run reports serious findings, with ready-to-paste GitHub Actions and GitLab CI examples.

How it works

Your pipeline downloads a small script from EyalSec, and the script does the rest: it installs es-python on the runner with your API key, runs your test command under es-python, waits a few seconds for the events to arrive, and asks EyalSec what that run reported. It prints a short summary with a link to the dashboard and fails the job when anything is at or above the severity you choose.

Each pipeline uses one machine, named by you (for example ci-myrepo). Every run reinstalls that same machine, so a pipeline takes one place in your machine limit however often it runs, and all of its findings collect under one name on the Events page. Reinstalling also means no long-lived machine credential has to be stored in your CI cache: each run starts from a fresh one-time install secret.

Reinstalling is also what keeps a pipeline working on fresh runners. A machine's install belongs to the computer it was installed on, and every CI job starts on a new one: an install copied from a cache onto the next runner would look like a clone. The cost is that each run downloads es-python again, which adds the download time to the job.

Before you start

You need three things.

  1. An API key. Create one under Settings > API access (see API keys). Use a full-access key, because installing is a write.
  2. The key in your CI secret store, under the name EYALSEC_API_KEY: a repository secret on GitHub, a masked CI/CD variable on GitLab. Keep the key out of workflow files, scripts and images.
  3. The runner's operating system and architecture, as EyalSec names them, for the first run. GitHub's ubuntu-latest and ubuntu-24.04 runners are ubuntu_24_04 / x86_64; ubuntu-24.04-arm is ubuntu_24_04 / aarch64. For a job running in a container image, use the image's distribution, such as debian_12 for the python:3.13-bookworm images. The full list is under supported targets.

es-python uses the packages installed for the runner's regular Python of the same version, so set up that Python and install your dependencies before the es-python step. The machine's Python version is 3.13 unless you choose another the first time with --python-version. See Packages.

GitHub Actions

This workflow runs pytest under es-python on every push and pull request.

name: tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-24.04
    # One run at a time per machine: a second install would stop the first
    # run's es-python from reporting.
    concurrency:
      group: es-python-${{ github.repository }}
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install -r requirements.txt pytest
      - name: Tests under es-python
        env:
          EYALSEC_API_KEY: ${{ secrets.EYALSEC_API_KEY }}
        run: |
          curl -fsSL https://eyalsec.com/ci.sh -o es-python-ci.sh
          sh es-python-ci.sh run --machine ci-${{ github.event.repository.name }} \
            --distro ubuntu_24_04 --arch x86_64 \
            -- es-python -m pytest

GitHub does not give secrets to workflows started by pull requests from forks, so the step stops there with EYALSEC_API_KEY is not set. To run the tests without es-python for those, add if: github.event.pull_request.head.repo.fork != true to the step and a plain pytest step with the opposite condition.

After the install, later steps of the same job can call es-python too: the script adds it to the job's PATH.

GitLab CI

The same in .gitlab-ci.yml. Add EYALSEC_API_KEY under Settings > CI/CD > Variables, masked. If you also mark it protected, it is only available to pipelines on protected branches and tags.

test:
  image: python:3.13-bookworm
  # One job at a time per machine.
  resource_group: es-python
  script:
    - pip install -r requirements.txt pytest
    - curl -fsSL https://eyalsec.com/ci.sh -o es-python-ci.sh
    - sh es-python-ci.sh run --machine "ci-$CI_PROJECT_NAME"
        --distro debian_12 --arch x86_64
        -- es-python -m pytest

Any other CI system works the same way: put the key in EYALSEC_API_KEY, download the script, and run it with sh.

What the job prints

After your command finishes, the script prints a summary like this one, then a final line that says whether the build passes.

es-python findings for machine ci-myrepo since 2026-10-07T12:00:00Z
  reported by this run: 4 (critical 1, high 1, medium 2, low 0, info 0)
  at or above high: 2, of which 1 new
    critical new  os system (event 90210, 1 occurrences in total)
    high     seen sqlite execute (event 88411, 14 occurrences in total)
  details: https://eyalsec.com/scanner/events?machine=ci-myrepo&sev=critical%2Chigh
es-python-ci: FAIL, 2 finding(s) at or above high

new means no earlier run had reached that finding; seen means one had. The value that reached the risky operation is never printed, because CI logs are often public. Follow the link, or look up the event number on the Events page, to see the whole flow.

If the summary says es-python reported nothing and you expected findings, check that your command really ran under es-python and that the sources you need are on (see Sources).

Options and exit codes

The script has three commands. Put every option before -- and your own command after it.

sh es-python-ci.sh run     [options] -- <command> [args...]   # install, run, check
sh es-python-ci.sh install [options]                          # install only
sh es-python-ci.sh check   [options] --since <time>           # check only
Option Environment variable Meaning
--machine NAME EYALSEC_MACHINE Required. The machine for this pipeline; created on the first run.
--distro ID, --arch ARCH EYALSEC_DISTRO, EYALSEC_ARCH The runner's OS, needed the first time. Given later, they move the machine to that OS.
--python-version V EYALSEC_PYTHON_VERSION The Python version, chosen the first time only.
--fail-on SEVERITY EYALSEC_FAIL_ON critical, high (the default), medium, low, info, or never to report without failing.
--new-only EYALSEC_NEW_ONLY=1 Fail only on findings no earlier run had reached.
--sources LIST EYALSEC_SOURCES The sources to switch on, comma-separated. The default is socket,file.
--wait SECONDS EYALSEC_WAIT How long to wait for events after your command ends. The default is 10.
--since TIME EYALSEC_SINCE For check: where the window starts. run sets it itself, just before your command.
--skip-install EYALSEC_SKIP_INSTALL=1 For run: use the es-python an earlier step installed.
--url URL EYALSEC_URL Your EyalSec address. A script downloaded from /ci.sh already knows it.

The exit code tells the pipeline what happened:

Exit code Meaning
0 Nothing at or above the threshold.
3 Findings at or above the threshold.
4 The script could not do its job: no key, a refused key, an install that failed, EyalSec unreachable. The message says which.
anything else Your own command failed, and its exit code is passed through. It always wins over the findings result.

Run the script with sh, as shown, not with . or source.

Install and check as separate steps

To run several commands under es-python, install once, run them yourself, and check once at the end. Switch the sources on yourself in this case, since only run does it for you.

sh es-python-ci.sh install --machine ci-myrepo --distro ubuntu_24_04 --arch x86_64
export PATH="$HOME/.local/bin:$PATH" ES2_MAKE_SOCKET_VULN=1 ES2_MAKE_FILE_VULN=1
since=$(date +%s)
es-python -m pytest tests/unit
es-python -m pytest tests/integration
sh es-python-ci.sh check --machine ci-myrepo --since "$since"

On GitHub, steps after install find es-python on their PATH and the window start in EYALSEC_SINCE without these lines. run --skip-install -- <command> runs one more command under an es-python installed earlier in the job, with the sources and the check included.

Sources

A source decides which data es-python treats as untrusted, and every source is off until something switches it on. The script switches on socket and file for the command it runs, through the environment variables ES2_MAKE_SOCKET_VULN and ES2_MAKE_FILE_VULN. Choose others with --sources, for example --sources socket,file,env, or --sources dashboard to set none and use only what the machine's Configure settings say.

These are defaults for the run: an explicit On or Off for the same source on the dashboard still wins. See How options and the dashboard combine.

Accepting a finding

A finding keeps failing the build on every run that reaches it, until the flow is fixed. When you decide a finding is acceptable, add a Hide rule for it, globally or on the CI machine. The check counts only what the Events page shows, so hidden findings and suppressed events never fail a build. Raising the threshold with --fail-on or using --new-only are the coarser alternatives.

Things to know

These points save most surprises.

  • One run per machine at a time. Each install replaces the previous credential, so if two jobs share a machine name, the first job's es-python stops reporting when the second installs. Serialize with concurrency (GitHub) or resource_group (GitLab), or give each job its own name.
  • Matrix builds need one name per leg. For example ci-myrepo-py3.12 and ci-myrepo-py3.13. Each name is one machine against your limit, and a machine's Python version is fixed when it is created.
  • The install secret is single-use and lasts 10 minutes. The script asks for it right before installing, so this only matters if you call the CI API yourself.
  • The API key acts as you. Anyone who can read it can do what your account can do through the API. Keep it in the secret store, and regenerate it if a log or a fork ever exposed it.
  • Events are kept like any other. CI findings count toward your event quota and appear on the Events page under the CI machine's name.

Something unclear or missing on this page? Email support@eyalsec.com.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo