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.
- An API key. Create one under Settings > API access (see API keys). Use a full-access key, because installing is a write.
- 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. - The runner's operating system and architecture, as EyalSec names them, for the first run. GitHub's
ubuntu-latestandubuntu-24.04runners areubuntu_24_04/x86_64;ubuntu-24.04-armisubuntu_24_04/aarch64. For a job running in a container image, use the image's distribution, such asdebian_12for thepython:3.13-bookwormimages. 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) orresource_group(GitLab), or give each job its own name. - Matrix builds need one name per leg. For example
ci-myrepo-py3.12andci-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.