Monitoring CI Secrets With eBPF (DNS + getenv)
Reading about hackerbot-claw and Shai Hulud made me curious: how would I know if something like that happened in my own CI/CD?
Last year, Shai Hulud compromised major projects like Zapier, Postman, ENS, AsyncAPI, PostHog, and Browserbase by injecting malicious npm dependencies. This year, hackerbot-claw spent a week autonomously scanning repos and exfiltrating secrets from CI environments. Both attacks succeeded because code running in CI had access to everything — environment variables.
Sources: Shai Hulud, hackerbot-claw
That’s when I built pipeline-monitor.
The Problem
CI/CD audit logs tell you:
- Workflow ran
- Step completed
They do not tell you:
- Which environment variables were read
- By which process
- At what timestamp
I found out the hard way that npm packages are not always what they seem. When a dependency runs in your pipeline, it has access to whatever environment variables are set. That includes high-value credentials like GITHUB_TOKEN (repo write access in many contexts) and cloud provider keys.
Once code can read your environment, it can just curl those secrets to an external server.
I built pipeline-monitor to answer one question: what if something like hackerbot-claw ran in my CI/CD? How would I know?
What I Built
pipeline-monitor is an eBPF-based auditor that runs inside GitHub Actions runners (or any Linux host with eBPF). It captures two things:
- DNS queries (UDP port 53) — see which domains are queried
- Environment variable access via libc
getenv— see what secrets get read
The result is a JSON report with timestamps, process names, and PIDs/TIDs. It does not record secret values (that would be dangerous), but it tells you which variables were accessed and when.
How It Works
The probes run at the kernel level, and a small Go collector formats the output:
sys_enter_connect— track sockets connecting to DNS port 53sys_enter_sendto— parse DNS question names from UDP payloadssys_enter_close— clean up tracked DNS socketsuprobe/getenv— capture libcgetenvcalls- events flow through a ring buffer to the Go userspace collector
Cutting the Noise
eBPF is powerful, but it is noisy. I did not want a report filled with PATH, HOME, and GITHUB_* reads.
The collector marks variables as sensitive when names contain SECRET, TOKEN, KEY, PASSWORD, or CREDENTIAL (case-insensitive). Everything else is filtered by default.
You can also provide an allowlist in .env.monitor:
- One entry per line
- Exact matches (e.g.,
AWS_ACCESS_KEY_ID) - Prefix matches end with
*(e.g.,AWS_*) - If
.env.monitorhas entries, only allowlisted variables (plus sensitive keyword matches) are recorded
Example .env.monitor:
# Exact matches
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
AWS_SESSION_TOKEN
# Prefix matches
AZURE_*
Example Output
{
"workflow_run_id": "12345",
"job_name": "build",
"step_name": "Run tests",
"events": [
{
"timestamp": "2026-03-03T10:00:01Z",
"type": "dns_query",
"pid": 1234,
"tid": 1234,
"process": "npm",
"domain": "registry.npmjs.org",
"port": 53
},
{
"timestamp": "2026-03-03T10:00:02Z",
"type": "env_get",
"pid": 1234,
"tid": 1234,
"process": "npm",
"var_name": "NPM_TOKEN",
"sensitive": true
}
]
}
This tells you:
- What was accessed (
NPM_TOKEN) - Who accessed it (
npmprocess) - When it happened (workflow run timestamp)
Running It in GitHub Actions
- name: Build pipeline monitor
run: |
cd .pipeline-monitor
make
- name: Start monitor
run: |
cd .pipeline-monitor
sudo sh -c 'bin/pipeline-monitor \
--workflow-run-id ${{ github.run_id }} \
--job-name ${{ github.job }} \
--step-name "job" \
--report /tmp/pipeline-audit-report.json \
> /tmp/pipeline-monitor.log 2>&1 & \
echo $! > /tmp/pipeline-monitor.pid'
- name: Build/test
run: |
# Your build/test commands go here
# npm ci
# npm test
- name: Stop monitor
run: |
sudo kill -INT "$(cat /tmp/pipeline-monitor.pid)"
for i in $(seq 1 30); do
kill -0 "$(cat /tmp/pipeline-monitor.pid)" 2>/dev/null || break
sleep 1
done
- name: Upload audit report
uses: actions/upload-artifact@v4
with:
name: pipeline-audit-report
path: /tmp/pipeline-audit-report.json
If getenv symbols are not found, set LIBC_PATH to your libc (for example /lib/x86_64-linux-gnu/libc.so.6).
What It Misses
eBPF is powerful, but it is not magic. Here is what it will not catch:
- Python
os.getenv(CPython caches env) - direct
/proc/self/environreads - musl or static binaries
- DNS-over-HTTPS or TCP DNS
It is audit only — it does not block or alert by itself. I am working on alerting and policy rules like “alert on GITHUB_TOKEN reads.”
What I Learned
Environment variables are a high-risk exfiltration path in CI. When code runs in your pipeline, it has access to everything.
eBPF gives you visibility into what actually gets read and which process did it. The .env.monitor file lets you tailor monitoring to the secrets you care about.
The bigger picture is that supply chain attacks have evolved from human operators to AI agents. The risk is not just “what code runs” anymore — it is “what secrets does that code have access to?”
I do not have a complete solution. But eBPF-based monitoring is a powerful tool for understanding what is actually happening in your CI/CD environment.
Disclosure: pipeline-monitor is open source at njannasch/pipeline-monitor. Use it at your own risk.
The views and opinions expressed here are my own and do not reflect those of my employer.