feat: linux ebpf security tracer project

eBPF-based security monitoring tool with process, file, network,
privilege escalation, and system call tracing via BCC. Includes
threat detection engine, Rich TUI renderer, and learn docs.
This commit is contained in:
CarterPerez-dev 2026-04-11 04:54:57 -04:00
parent d856257b3c
commit d292f1e4b7
33 changed files with 4960 additions and 848 deletions

View File

@ -0,0 +1,13 @@
docs/
__pycache__/
*.pyc
.env
.venv/
*.o
*.so
.mypy_cache/
.ruff_cache/
.pytest_cache/
dist/
build/
*.egg-info/

View File

@ -0,0 +1,3 @@
[style]
based_on_style = pep8
column_limit = 75

View File

@ -0,0 +1,185 @@
# Linux eBPF Security Tracer
Real-time syscall tracing tool using eBPF for security observability. Monitors process execution, file access, network connections, privilege changes, and system operations to detect suspicious behavior patterns.
## Features
- Real-time syscall monitoring via eBPF tracepoints
- 10 built-in detection rules mapped to MITRE ATT&CK techniques
- Correlated event analysis (reverse shell detection, privilege escalation)
- Multiple output formats: live color-coded stream, JSON, table summary
- Configurable severity filtering (LOW, MEDIUM, HIGH, CRITICAL)
- Process, file, network, privilege, and system event categories
- Event enrichment from /proc filesystem
- Clean signal handling and eBPF program cleanup
## Prerequisites
- Linux kernel 5.8+ (ring buffer support)
- Root privileges (required for eBPF)
- Python 3.10+
- BCC (BPF Compiler Collection) with Python bindings
## Quick Start
```bash
# Install system dependencies and Python packages
./install.sh
# Start tracing all syscalls
sudo uv run ebpf-tracer
# JSON output, only MEDIUM+ severity
sudo uv run ebpf-tracer -f json -s MEDIUM
# Only network events
sudo uv run ebpf-tracer -t network
# Only show detection alerts
sudo uv run ebpf-tracer --detections
# Filter by process name
sudo uv run ebpf-tracer -c nginx
# Write events to file while streaming
sudo uv run ebpf-tracer -o events.jsonl
```
## Usage
```
ebpf-tracer [OPTIONS]
Options:
-f, --format Output format: json, table, live [default: live]
-s, --severity Minimum severity: LOW, MEDIUM, [default: LOW]
HIGH, CRITICAL
-p, --pid Filter by specific PID
-c, --comm Filter by process name
-t, --type Event category: process, file, [default: all]
network, privilege, system, all
--no-enrich Disable /proc enrichment
-o, --output Also write events to file
--detections Show only detection alerts
--version Show version
--help Show help
```
## Detection Rules
| ID | Name | Severity | MITRE ATT&CK | Trigger |
|----|------|----------|--------------|---------|
| D001 | Privilege Escalation | CRITICAL | T1548 | setuid(0) by non-root |
| D002 | Sensitive File Read | MEDIUM | T1003.008 | /etc/shadow access by non-root |
| D003 | SSH Key Access | MEDIUM | T1552.004 | SSH key file access |
| D004 | Process Injection | MEDIUM | T1055.008 | ptrace ATTACH/SEIZE |
| D005 | Kernel Module Load | HIGH | T1547.006 | init_module syscall |
| D006 | Reverse Shell | CRITICAL | T1059.004 | connect + shell execve sequence |
| D007 | Persistence via Cron | MEDIUM | T1053.003 | Write to cron directories |
| D008 | Persistence via Systemd | MEDIUM | T1543.002 | Write to systemd unit dirs |
| D009 | Log Tampering | MEDIUM | T1070.002 | Log file deletion/truncation |
| D010 | Suspicious Mount | HIGH | T1611 | mount syscall |
## Architecture
```
User Space
┌─────────┐ ┌──────────────┐ ┌─────────────────┐
│ CLI │──▶│ Event Engine │──▶│ Output Renderer │
│ (Typer) │ │ (Processor + │ │ (JSON / Table / │
│ │ │ Detector) │ │ Live Stream) │
└─────────┘ └──────┬───────┘ └─────────────────┘
┌──────┴───────┐
│ BPF Loader │
│ (BCC/Python)│
└──────┬───────┘
─────────────────────┼──────────────────────────────
Kernel Space │
┌──────┴───────┐
│ Ring Buffer │
└──────┬───────┘
┌───────────────┼───────────────────┐
│ eBPF C Tracepoint Programs │
│ ┌─────────┐┌────────┐┌─────────┐ │
│ │ Process ││ File ││ Network │ │
│ └─────────┘└────────┘└─────────┘ │
│ ┌──────────┐┌────────┐ │
│ │Privilege ││ System │ │
│ └──────────┘└────────┘ │
└───────────────────────────────────┘
```
## Monitored Syscalls
| Category | Syscalls | Purpose |
|----------|----------|---------|
| Process | execve, clone | New process creation |
| File | openat, unlinkat, renameat2 | File access and manipulation |
| Network | connect, accept4, bind, listen | Network activity |
| Privilege | setuid, setgid | Privilege changes |
| System | ptrace, mount, init_module | System-level operations |
## Project Structure
```
src/
├── main.py # CLI entrypoint (Typer)
├── config.py # Constants, event types, detection rules
├── loader.py # BCC program loader and ring buffer setup
├── processor.py # Event parsing, enrichment, filtering
├── detector.py # Detection engine with stateless and stateful rules
├── renderer.py # Output formatters (JSON, live, table)
└── ebpf/
├── process_tracer.c # execve, clone tracepoints
├── file_tracer.c # openat, unlinkat, renameat2 tracepoints
├── network_tracer.c # connect, accept4, bind, listen tracepoints
├── privilege_tracer.c # setuid, setgid tracepoints
└── system_tracer.c # ptrace, mount, init_module tracepoints
```
## Example Output
### Live Mode (default)
```
[14:30:01] LOW execve pid=1234 comm=bash /usr/bin/curl
[14:30:01] CRITICAL connect pid=1234 comm=nc 10.0.0.1:4444 [Reverse Shell]
[14:30:02] MEDIUM openat pid=5678 comm=python3 /etc/shadow [Sensitive File Read]
[14:30:03] HIGH init_module pid=9012 comm=insmod [Kernel Module Load]
```
### JSON Mode
```json
{"timestamp":"2026-04-08T14:30:01+00:00","event_type":"connect","pid":1234,"comm":"nc","severity":"CRITICAL","detection":"Reverse Shell","mitre_id":"T1059.004","dest_ip":"10.0.0.1","dest_port":4444}
```
## Development
```bash
# Install dev dependencies
uv sync
# Run unit tests
just test
# Lint
just lint
# Format
just format
```
## How It Works
1. **eBPF C programs** attach to kernel tracepoints for specific syscalls
2. When a traced syscall fires, the eBPF program captures event data (PID, UID, filename, etc.) and pushes it to a shared ring buffer
3. **Python (BCC)** polls the ring buffer and deserializes events via ctypes
4. The **processor** enriches events with data from /proc (parent process, username)
5. The **detection engine** evaluates each event against stateless rules (single-event patterns) and stateful rules (correlated event sequences)
6. The **renderer** outputs events in the selected format with severity-based color coding
## License
MIT

View File

@ -0,0 +1,135 @@
#!/usr/bin/env bash
# ©AngelaMos | 2026
# install.sh
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
fail() { echo -e "${RED}[-]${NC} $1"; exit 1; }
check_root() {
if [[ $EUID -ne 0 ]]; then
warn "Some steps require root. You may be prompted for sudo."
fi
}
check_kernel() {
local version
version=$(uname -r | cut -d. -f1-2)
local major minor
major=$(echo "$version" | cut -d. -f1)
minor=$(echo "$version" | cut -d. -f2)
if [[ $major -lt 5 ]] || { [[ $major -eq 5 ]] && [[ $minor -lt 8 ]]; }; then
fail "Kernel $version detected. Requires Linux 5.8+ for ring buffer support."
fi
info "Kernel version $(uname -r) meets requirements (5.8+)"
}
detect_distro() {
if [[ -f /etc/os-release ]]; then
. /etc/os-release
echo "$ID"
else
echo "unknown"
fi
}
install_system_deps() {
local distro
distro=$(detect_distro)
case "$distro" in
ubuntu|debian|pop|linuxmint|kali)
info "Detected Debian-based system ($distro)"
sudo apt-get update -qq
sudo apt-get install -y -qq \
bpfcc-tools \
python3-bpfcc \
libbpfcc-dev \
linux-headers-"$(uname -r)" \
2>/dev/null || true
;;
fedora)
info "Detected Fedora"
sudo dnf install -y \
bcc-tools \
python3-bcc \
bcc-devel \
kernel-headers \
kernel-devel \
2>/dev/null || true
;;
rhel|centos|rocky|alma)
info "Detected RHEL-based system ($distro)"
sudo yum install -y \
bcc-tools \
python3-bcc \
bcc-devel \
kernel-headers \
kernel-devel \
2>/dev/null || true
;;
arch|manjaro|endeavouros)
info "Detected Arch-based system ($distro)"
sudo pacman -Sy --noconfirm \
bcc \
bcc-tools \
python-bcc \
linux-headers \
2>/dev/null || true
;;
*)
warn "Unknown distro: $distro"
warn "Install manually: bcc-tools, python3-bcc, linux-headers"
;;
esac
}
install_python_deps() {
if ! command -v uv &>/dev/null; then
info "Installing uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
fi
info "Installing Python dependencies with uv..."
uv sync
}
verify_install() {
info "Verifying installation..."
if python3 -c "import bcc" 2>/dev/null; then
info "BCC Python bindings: OK"
else
warn "BCC Python bindings not found in system Python"
warn "Make sure python3-bpfcc (Debian) or python3-bcc (Fedora/Arch) is installed"
fi
if [[ -d /sys/kernel/debug/tracing ]]; then
info "Tracing filesystem: OK"
else
warn "Tracing filesystem not mounted. Try: sudo mount -t debugfs debugfs /sys/kernel/debug"
fi
}
main() {
info "eBPF Security Tracer - Installation"
echo ""
check_root
check_kernel
install_system_deps
install_python_deps
verify_install
echo ""
info "Installation complete. Run with: sudo uv run ebpf-tracer"
}
main "$@"

View File

@ -0,0 +1,27 @@
# ©AngelaMos | 2026
# justfile
default:
@just --list
lint:
uv run ruff check .
uv run mypy src/
format:
uv run yapf -r -i src/ tests/
check-format:
uv run yapf -r -d src/ tests/
test:
uv run pytest tests/ -m "not integration"
test-all:
sudo uv run pytest tests/
run *ARGS:
sudo uv run ebpf-tracer {{ARGS}}
install:
./install.sh

View File

@ -0,0 +1,127 @@
# eBPF Security Tracer - Overview
## What This Is
A real-time Linux syscall tracer built on eBPF that monitors process execution, file access, network connections, privilege changes, and system operations. It evaluates events against detection rules mapped to MITRE ATT&CK techniques and outputs color-coded alerts.
## Why This Matters
Traditional security monitoring relies on log aggregation after the fact. By the time you check syslog, an attacker may have already wiped it. eBPF lets you observe syscalls as they happen, at the kernel level, with near-zero overhead. This is how modern security tools like Falco, Tetragon, and Tracee work under the hood.
### Real World Scenarios
1. **Incident Response**: During a live breach, you need to see what processes are running, what files they're touching, and where they're connecting. This tool provides that visibility in real time without deploying a full SIEM stack.
2. **Server Hardening Validation**: After locking down a production server, run the tracer to verify that only expected processes access sensitive files like `/etc/shadow` or SSH keys. Any unexpected access triggers an alert.
3. **Container Security**: In Kubernetes environments, containers should never load kernel modules or mount host filesystems. eBPF-based tracing catches these escape attempts at the syscall level before they succeed.
## What You'll Learn
### Security Concepts
- Syscall-level observability and why it matters for defense
- MITRE ATT&CK technique identification from raw syscall data
- Detection engineering: turning syscall patterns into security rules
- Behavioral analysis vs signature-based detection
### Technical Skills
- Writing eBPF C programs that attach to kernel tracepoints
- Using BCC (BPF Compiler Collection) Python bindings
- Ring buffer architecture for kernel-to-userspace communication
- Event correlation with sliding window algorithms
- Structured security event output (JSON, severity classification)
### Tools
- BCC framework and eBPF compilation pipeline
- Python CLI tooling with Typer and Rich
- ruff, mypy, yapf for code quality
- uv for Python package management
## Prerequisites
### Required Knowledge
- Basic Linux administration (processes, files, permissions, networking)
- Python fundamentals (functions, classes, data structures)
- Some familiarity with C syntax (the eBPF programs are small but you need to read them)
- Understanding of what system calls are (even if you've never traced them)
### Required Tools
- Linux with kernel 5.8+ (check with `uname -r`)
- Root access (eBPF requires CAP_SYS_ADMIN)
- Python 3.10+
- uv package manager
- BCC tools (installed via `install.sh`)
### Nice to Have
- Familiarity with strace or ltrace
- Basic networking concepts (TCP/IP, sockets)
- Experience with security monitoring or SIEM tools
## Quick Start
```bash
git clone https://github.com/CarterPerez-dev/Cybersecurity-Projects.git
cd Cybersecurity-Projects/PROJECTS/beginner/linux-ebpf-security-tracer
# Install everything
./install.sh
# Start tracing
sudo uv run ebpf-tracer
# In another terminal, trigger a detection:
cat /etc/shadow # triggers "Sensitive File Read"
```
Expected output:
```
eBPF Security Tracer v1.0.0
Format: live | Min severity: LOW | Type: all
Press Ctrl+C to stop
[14:30:01] LOW execve pid=1234 comm=bash /usr/bin/cat
[14:30:01] MEDIUM openat pid=1234 comm=cat /etc/shadow [Sensitive File Read]
```
## Project Structure
```
src/
├── main.py # CLI entrypoint
├── config.py # All constants and detection rule metadata
├── loader.py # BCC loader, ring buffer setup, signal handling
├── processor.py # Raw event parsing, enrichment, filtering
├── detector.py # Detection engine (stateless + stateful rules)
├── renderer.py # Output formatters (JSON, live, table)
└── ebpf/ # eBPF C programs compiled by BCC at runtime
├── process_tracer.c
├── file_tracer.c
├── network_tracer.c
├── privilege_tracer.c
└── system_tracer.c
```
## Next Steps
- [01-CONCEPTS.md](01-CONCEPTS.md) - eBPF fundamentals, syscall tracing, security observability
- [02-ARCHITECTURE.md](02-ARCHITECTURE.md) - System design, ring buffers, detection pipeline
- [03-IMPLEMENTATION.md](03-IMPLEMENTATION.md) - Code walkthrough of each module
- [04-CHALLENGES.md](04-CHALLENGES.md) - Extension ideas and challenges
## Common Issues
**"Error: eBPF tracing requires root privileges"**
Run with sudo: `sudo uv run ebpf-tracer`. eBPF programs need CAP_SYS_ADMIN to load.
**"Kernel X.Y detected. Requires 5.8+"**
Your kernel is too old for ring buffer support. Upgrade your kernel or use a VM/container with a newer kernel.
**"BCC Python bindings not found"**
Install the system package: `sudo apt install python3-bpfcc` (Debian/Ubuntu) or `sudo dnf install python3-bcc` (Fedora). BCC is not pip-installable, it must come from your distro's package manager.
## Related Projects
- [Simple Port Scanner](../../simple-port-scanner/) - Network reconnaissance basics
- [Simple Vulnerability Scanner](../../simple-vulnerability-scanner/) - Vulnerability identification
- [Linux CIS Hardening Auditor](../../linux-cis-hardening-auditor/) - System hardening

View File

@ -0,0 +1,230 @@
# Concepts - eBPF, Syscalls, and Security Observability
## eBPF: Programmable Kernel Observability
### What It Is
eBPF (extended Berkeley Packet Filter) is a technology that lets you run small programs inside the Linux kernel without writing a kernel module or modifying the kernel source. Think of it as a safe, sandboxed scripting language for the kernel.
When you load an eBPF program, the kernel's verifier checks it for safety (no infinite loops, no out-of-bounds memory access, no crashing the kernel), then JIT-compiles it to native machine code. This means eBPF programs run at near-native speed with strong safety guarantees.
### Why It Matters for Security
Before eBPF, you had two options for kernel-level visibility:
1. **Kernel modules** - Full access, but a bug crashes the system. Loading untrusted code into the kernel is inherently risky.
2. **System call tracing (strace/ptrace)** - Safe but slow. ptrace-based tracing introduces 10-100x overhead on traced processes.
eBPF gives you kernel-level visibility with user-space safety. The performance overhead is typically under 1%, and the verifier guarantees your program can't crash the kernel.
This is why every major cloud security tool released since 2020 (Falco, Tetragon, Tracee, Datadog's runtime security) uses eBPF as its foundation.
### How It Works
```
Your Python Script
BCC Compiler
(Clang/LLVM)
eBPF Bytecode
Kernel Verifier ──▶ Rejects unsafe programs
JIT Compiler
Native Machine Code
(attached to tracepoint)
Fires on every matching syscall
Ring Buffer ──▶ Your Python callback
```
### BCC vs libbpf
There are two main frameworks for writing eBPF programs:
**BCC (BPF Compiler Collection)** compiles your eBPF C code at runtime using Clang/LLVM. The advantage is rapid development: you write C code as a Python string, load it, and go. The disadvantage is that every host needs LLVM and kernel headers installed, and each program uses ~80MB of memory.
**libbpf with CO-RE** (Compile Once, Run Everywhere) compiles your eBPF program once at build time. The binary works across kernel versions thanks to BTF (BPF Type Format) metadata. Production tools like Tetragon use this approach because it's lighter (~9MB) and doesn't need compiler toolchains on production hosts.
This project uses BCC because we're building a learning tool, not a production agent. The Python API makes the code readable, and runtime compilation lets you experiment without a build step.
## System Calls: The Kernel's Front Door
### What System Calls Are
Every interaction between a user-space program and the kernel goes through system calls. When `cat` reads a file, it calls `openat()` to get a file descriptor, `read()` to get the contents, and `write()` to print to stdout. When `curl` connects to a server, it calls `socket()`, `connect()`, and `read()`.
There's no way around this. Even if malware is fully in-memory, even if it's written in assembly, it still needs to make syscalls to do anything useful. This makes syscall tracing a powerful detection mechanism that's very hard to evade.
### Security-Relevant Syscalls
Not all ~300+ Linux syscalls matter for security. Here are the ones this tool traces and why:
**Process execution** - `execve` fires every time a new program runs. This is the most important syscall for security monitoring. Almost every attack involves executing something, whether it's a shell, a payload, or a legitimate tool being abused.
**File access** - `openat` shows which files processes are reading or writing. An attacker reading `/etc/shadow` or writing to `/etc/cron.d/` tells a clear story.
**Network activity** - `connect` reveals outbound connections. A web server suddenly connecting to an IP in Eastern Europe on port 4444 is a red flag. `bind` and `listen` show processes opening ports for inbound connections (bind shells).
**Privilege changes** - `setuid` and `setgid` show privilege transitions. A process calling `setuid(0)` to become root is exactly what privilege escalation looks like.
**System operations** - `ptrace` is used for debugging but also for process injection (MITRE ATT&CK T1055.008). `mount` can indicate container escape attempts. `init_module` loads kernel modules, which is how rootkits install themselves.
### The Syscall Tracing Surface
```
User Space Process
│ execve("/bin/bash", ...)
│ openat("/etc/shadow", O_RDONLY)
│ connect(sockfd, {ip=10.0.0.1, port=4444})
│ setuid(0)
┌─────────────────────────┐
│ Syscall Entry Point │◀── eBPF tracepoint here
│ (kernel boundary) │
└─────────────────────────┘
Kernel implementation
```
## Detection Engineering
### From Syscalls to Security Alerts
A single syscall in isolation is rarely suspicious. `openat` fires thousands of times per second on a busy system. The art of detection engineering is identifying which patterns, either single events with unusual parameters or sequences of events, indicate malicious activity.
### Stateless Detection
Some events are suspicious on their own:
- `setuid(0)` called by a process running as UID 1000 is almost always an escalation attempt
- `openat("/etc/shadow")` by a Python script is worth investigating
- `init_module()` loading a kernel module is always notable
- `ptrace(PTRACE_ATTACH, target_pid)` is a code injection primitive
These are "stateless" detections because each event is evaluated independently.
### Stateful Detection (Event Correlation)
Other threats only become visible when you correlate multiple events:
**Reverse shell pattern**: An attacker on a compromised server needs to get an interactive shell back to their machine. The classic approach:
```
1. socket(AF_INET, SOCK_STREAM) # create TCP socket
2. connect(sockfd, attacker_ip) # connect to attacker
3. dup2(sockfd, 0) # redirect stdin to socket
4. dup2(sockfd, 1) # redirect stdout to socket
5. dup2(sockfd, 2) # redirect stderr to socket
6. execve("/bin/bash") # spawn shell
```
No single syscall here is suspicious. Programs create sockets and connect to servers all the time. Shells are spawned constantly. But a `connect` followed by a shell `execve` from the same PID within seconds is a strong reverse shell indicator.
This tool implements this as a stateful rule: it maintains a sliding window of recent events per PID. When a shell execve arrives, it checks if there was a recent `connect` from the same PID or its parent.
### Real World Examples
**2021 Log4Shell (CVE-2021-44228)**: The initial exploit triggered a JNDI lookup that downloaded and executed a payload. From a syscall perspective: the Java process (unexpected) called `connect()` to an external LDAP server, downloaded a class file, and then `execve()` spawned a shell. eBPF-based tools detected this in real time while WAFs were still being updated with signatures.
**2020 SolarWinds Supply Chain Attack**: The compromised Orion software made unusual outbound connections to `avsvmcloud.com`. Syscall tracing would have shown the Orion process calling `connect()` to DNS/HTTP endpoints that weren't in its normal communication pattern.
**Kubernetes Container Escapes**: CVE-2022-0185 exploited a heap overflow in the kernel's filesystem context handling. The exploit sequence involved `mount()` syscalls with crafted parameters from within a container, something that eBPF-based tools like Tetragon are specifically designed to catch.
## MITRE ATT&CK Mapping
The MITRE ATT&CK framework provides a common language for categorizing adversary behavior. This tool maps each detection rule to specific ATT&CK techniques:
| Detection | Technique | Tactic |
|-----------|-----------|--------|
| Privilege Escalation | T1548 - Abuse Elevation Control | Privilege Escalation |
| Sensitive File Read | T1003.008 - /etc/passwd and /etc/shadow | Credential Access |
| SSH Key Access | T1552.004 - Private Keys | Credential Access |
| Process Injection | T1055.008 - Ptrace System Calls | Defense Evasion |
| Kernel Module Load | T1547.006 - Kernel Modules | Persistence |
| Reverse Shell | T1059.004 - Unix Shell | Execution |
| Persistence via Cron | T1053.003 - Cron | Persistence |
| Log Tampering | T1070.002 - Clear Linux Logs | Defense Evasion |
## Common Pitfalls
### Pitfall: Assuming Syscall Names Are Stable
System call naming varies between architectures and kernel versions. On x86_64, `open()` was replaced by `openat()` as the primary file-opening syscall. Always use the tracepoint interface (`syscalls:sys_enter_openat`) rather than kprobes on raw syscall functions, because tracepoints are stable ABI.
### Pitfall: Ignoring Event Volume
On a busy server, `execve` and `openat` fire hundreds of times per second. A detection engine that does expensive processing per event will fall behind. This tool uses a ring buffer (not perf buffer) and keeps detection logic simple for this reason.
### Pitfall: Over-Alerting
If every `openat` of `/etc/passwd` triggers an alert, operators will disable the tool within a day. Good detection engineering means understanding what's normal. Root reading `/etc/shadow` is expected (PAM does this for every login). A Python script reading it is unusual. Context matters.
## How Concepts Connect
```
eBPF Programs ──────────────────┐
(C code in kernel) │
│ │
│ capture syscall args │ compile + load
│ │
▼ │
Ring Buffer ◀───────────────────┘
│ via BCC Python
│ events flow to
│ user space
Detection Engine
│ evaluate against rules
│ correlate sequences
MITRE ATT&CK Mapping
│ classify severity
Alert Output
```
## Industry Standards
- **MITRE ATT&CK for Linux** - Framework for categorizing adversary behavior on Linux systems
- **NIST SP 800-137** - Information Security Continuous Monitoring, which eBPF-based tools directly support
- **CIS Controls v8, Control 8** - Audit Log Management. eBPF tracing provides the raw audit data
## Testing Your Understanding
1. Why can't malware avoid syscall-based detection by using direct kernel memory access from user space?
2. You see this sequence from PID 4521: `socket(AF_INET, SOCK_STREAM)`, then `connect(10.0.0.5:443)`, then `execve("/usr/bin/curl")`. Is this a reverse shell? Why or why not?
3. A detection rule triggers on every `openat("/etc/passwd")`. On a server with 100 users logging in per hour, how many false positives per hour would you expect? How would you reduce them?
4. What's the difference between attaching an eBPF program to a kprobe vs a tracepoint? Which is more reliable for production use?
## Further Reading
### Essential
- [ebpf.io](https://ebpf.io) - Official eBPF documentation and learning resources
- [BCC Reference Guide](https://github.com/iovisor/bcc/blob/master/docs/reference_guide.md) - API reference for all BCC features
- [MITRE ATT&CK for Linux](https://attack.mitre.org/matrices/enterprise/linux/) - Full technique matrix
### Deep Dive
- [Learning eBPF by Liz Rice](https://www.oreilly.com/library/view/learning-ebpf/9781098135119/) - Comprehensive book on eBPF programming
- [BPF Performance Tools by Brendan Gregg](https://www.brendangregg.com/bpf-performance-tools-book.html) - Reference for eBPF-based system analysis
- [Falco Rules Repository](https://github.com/falcosecurity/rules) - See how a production tool defines detection rules

View File

@ -0,0 +1,297 @@
# Architecture - System Design and Technical Decisions
## High Level Architecture
```
┌────────────────────────────────────────────────────────┐
│ User Space │
│ │
│ ┌─────────┐ │
│ │ main.py │ CLI entrypoint │
│ │ (Typer) │ parses args, wires components │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ ┌─────────────┐ ┌────────────────┐ │
│ │loader.py │──▶│processor.py │──▶│ renderer.py │ │
│ │ │ │ │ │ │ │
│ │ Compiles │ │ Parses raw │ │ JSON / Live / │ │
│ │ & loads │ │ events, │ │ Table output │ │
│ │ eBPF C │ │ enriches │ │ │ │
│ │ programs │ │ from /proc │ └────────────────┘ │
│ │ │ │ │ │
│ │ Sets up │ │ Filters by │ │
│ │ ring buf │ │ severity, │ │
│ │ callback │ │ PID, comm │ ┌────────────────┐ │
│ └──────────┘ │ │──▶│ detector.py │ │
│ └─────────────┘ │ │ │
│ │ Stateless │ │
│ │ rules + │ │
│ │ stateful │ │
│ │ correlation │ │
│ └────────────────┘ │
├────────────────────────────────────────────────────────┤
│ Kernel Space │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Ring Buffer (shared) │ │
│ │ BPF_RINGBUF_OUTPUT, 256KB │ │
│ └──────────┬──────────┬──────────┬─────────────┘ │
│ │ │ │ │
│ ┌──────────┴───┐ ┌────┴────┐ ┌──┴──────────┐ │
│ │process_tracer│ │file_ │ │network_ │ │
│ │ .c │ │tracer.c │ │tracer.c │ │
│ │ │ │ │ │ │ │
│ │ sys_enter_ │ │sys_enter│ │sys_enter_ │ │
│ │ execve │ │_openat │ │connect │ │
│ │ sys_enter_ │ │sys_enter│ │sys_enter_ │ │
│ │ clone │ │_unlinkat│ │accept4 │ │
│ │ │ │sys_enter│ │sys_enter_ │ │
│ │ │ │_rename │ │bind/listen │ │
│ └─────────────┘ └─────────┘ └─────────────┘ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │privilege_ │ │system_ │ │
│ │tracer.c │ │tracer.c │ │
│ │ │ │ │ │
│ │sys_enter_ │ │sys_enter_ │ │
│ │setuid │ │ptrace │ │
│ │sys_enter_ │ │sys_enter_ │ │
│ │setgid │ │mount │ │
│ │ │ │sys_enter_ │ │
│ │ │ │init_module │ │
│ └─────────────┘ └─────────────┘ │
└────────────────────────────────────────────────────────┘
```
## Component Breakdown
### main.py - CLI and Orchestration
Parses command line arguments via Typer and wires together the loader, processor, detector, and renderer. Handles signal-based shutdown. This is the thinnest layer: it contains no business logic, just plumbing.
### loader.py - eBPF Program Lifecycle
Reads `.c` files from the `ebpf/` directory, compiles them via BCC, attaches them to kernel tracepoints, and sets up ring buffer polling. Also handles cleanup: detaching eBPF programs and freeing BPF objects when the tool stops.
### processor.py - Event Parsing and Enrichment
Defines `RawEvent` (a ctypes Structure mirroring the C struct) and `TracerEvent` (a Python dataclass with enriched fields). Converts raw bytes from the ring buffer into structured Python objects. Enriches events with data from `/proc` (parent process name, username resolution). Implements filtering logic.
### detector.py - Detection Engine
Contains all security detection logic. Stateless rules evaluate individual events (e.g., "is this a setuid(0) by non-root?"). Stateful rules correlate events across time using a per-PID sliding window (e.g., "was there a connect before this shell execve?"). Returns Detection objects that get stamped onto events.
### renderer.py - Output Formatting
Three output modes. `LiveRenderer` uses Rich for color-coded streaming. `JsonRenderer` writes one JSON object per line to stdout. `TableRenderer` buffers events and periodically renders Rich tables. `FileRenderer` writes JSON to a file alongside any other output mode.
### config.py - Constants and Rule Metadata
All magic numbers, file paths, detection rule definitions, severity levels, and event type mappings live here. Nothing is hardcoded elsewhere. Changing a detection rule's severity or adding a new sensitive file path only requires editing this file.
### ebpf/*.c - Kernel-Space Programs
Five C files, one per syscall category. Each defines a `TRACEPOINT_PROBE` that fires on the corresponding `syscalls:sys_enter_*` event. Programs capture event data into a shared struct and push it to the ring buffer. The C code is intentionally minimal, all detection logic stays in Python.
## Data Flow
### Step by Step: From Syscall to Alert
```
1. Process calls execve("/bin/bash")
2. Kernel hits tracepoint syscalls:sys_enter_execve
3. eBPF program (process_tracer.c) fires:
- Reserves space in ring buffer
- Fills struct: pid, ppid, uid, comm, filename, timestamp
- Submits to ring buffer
4. Python callback (on_event in main.py) fires:
- parse_raw_event() casts raw bytes to RawEvent ctypes struct
- Converts to TracerEvent dataclass
- Decodes comm/filename from null-terminated bytes
- Converts kernel timestamp to wall clock datetime
- Resolves UID to username via pwd module
5. enrich_event() adds parent process name from /proc
6. detector.evaluate() checks:
- Stateless: Is the event itself suspicious? No.
- Stateful: Is this a shell? Yes (bash). Was there a
recent connect from this PID? Check history deque.
If yes -> Detection("Reverse Shell", CRITICAL)
7. should_include() applies user's filters:
- Severity >= minimum? PID matches? Comm matches?
8. renderer.render() outputs:
[14:30:01] CRITICAL execve pid=1234 comm=bash
/bin/bash [Reverse Shell]
```
## Design Patterns
### Pattern: Kernel Simplicity, Userspace Complexity
The eBPF C programs do the bare minimum: read syscall arguments, fill a struct, push to ring buffer. All the interesting work (detection, correlation, enrichment, formatting) happens in Python.
Why? eBPF programs run inside the kernel with strict constraints:
- 512-byte stack limit
- No dynamic memory allocation
- No string manipulation beyond `bpf_probe_read_*`
- The verifier rejects anything complex
Moving logic to userspace also means you can change detection rules without recompiling eBPF programs, and you can unit test detection logic without root privileges.
### Pattern: Single Event Struct
All five eBPF programs use the same `struct event` layout, even though not every field is relevant to every event type. A process event doesn't need `addr_v4` and a network event doesn't need `filename`, but they share the same struct.
This seems wasteful (the struct is ~300 bytes with mostly-zero fields for most events), but it has major advantages:
- One `RawEvent` ctypes definition in Python, not five
- One ring buffer callback, not five
- Simpler code, fewer bugs
The alternative (per-type structs with discriminated unions) would save memory but add complexity that isn't justified at this scale.
### Pattern: Deque-Based Correlation
The detection engine maintains a `collections.deque` per PID with a max length. Events older than the correlation window are pruned on each evaluation. This gives O(1) append and O(n) scanning where n is small (max 64 events per PID, 10-second window).
For a tool tracing a typical server, this means ~1000 deques in memory (one per active PID), each holding a few events. Total memory for correlation: a few megabytes at most.
### Trade-offs
**Ring buffer vs perf buffer**: Ring buffer (used here) requires kernel 5.8+ but provides event ordering guarantees and lower overhead via the reserve/submit zero-copy API. Perf buffer works on older kernels (4.4+) but has per-CPU allocation waste and no ordering guarantee.
**BCC vs libbpf**: BCC requires LLVM on the host and uses ~80MB per tool. libbpf with CO-RE produces ~9MB standalone binaries. For a learning tool, BCC's Python API and iterative development experience win. For production, you'd switch to libbpf.
**Tracepoints vs kprobes**: Tracepoints are stable ABI, they won't break between kernel versions. Kprobes hook arbitrary kernel functions and can break when internal APIs change. This tool uses tracepoints exclusively.
## Data Models
### RawEvent (C struct / ctypes)
| Field | Type | Bytes | Purpose |
|-------|------|-------|---------|
| timestamp_ns | u64 | 8 | Kernel monotonic clock |
| pid | u32 | 4 | Process ID |
| ppid | u32 | 4 | Parent process ID |
| uid | u32 | 4 | User ID |
| gid | u32 | 4 | Group ID |
| event_type | u32 | 4 | Enum: EXECVE=1...INIT_MODULE=14 |
| ret_val | u32 | 4 | Return value or flags |
| comm | char[16] | 16 | Process name (TASK_COMM_LEN) |
| filename | char[256] | 256 | File path or device name |
| addr_v4 | u32 | 4 | IPv4 address (network order) |
| port | u16 | 2 | Port number (host order) |
| protocol | u16 | 2 | Address family (AF_INET=2) |
| target_uid | u32 | 4 | Target UID for setuid |
| target_gid | u32 | 4 | Target GID for setgid |
| ptrace_request | u32 | 4 | ptrace operation type |
| target_pid | u32 | 4 | Target PID for ptrace |
| **Total** | | **324** | |
### TracerEvent (Python dataclass)
Extends RawEvent with:
- `timestamp` as `datetime` (converted from kernel nanoseconds)
- `username` resolved from UID
- `severity`, `detection`, `detection_id`, `mitre_id` from detection engine
- `extra` dict for enrichment data (parent_comm, etc.)
## Security Architecture
### Privilege Model
The tool requires root (CAP_SYS_ADMIN) to load eBPF programs. It checks at startup with `os.geteuid()` and exits with a clear message if not root.
### eBPF Safety
The kernel verifier ensures eBPF programs cannot:
- Access memory outside their stack or BPF maps
- Execute unbounded loops
- Call arbitrary kernel functions
- Crash the kernel
### Cleanup
Signal handlers (SIGINT, SIGTERM) trigger clean shutdown. The `TracerLoader.cleanup()` method calls `bpf.cleanup()` on each BPF object, which detaches tracepoints and frees kernel resources. A `try/finally` block in `main.py` ensures cleanup runs even on exceptions.
### Input Validation
The tool reads from kernel ring buffers (trusted) and /proc (trusted). There's no user input beyond CLI arguments, which Typer validates via type annotations.
## Configuration
All configuration lives in `config.py` as module-level constants:
| Setting | Value | Purpose |
|---------|-------|---------|
| RING_BUFFER_BYTES | 256KB | Size of shared ring buffer |
| CORRELATION_WINDOW_SEC | 10 | Sliding window for stateful detection |
| MAX_EVENTS_PER_PID | 64 | Max events in correlation deque |
| MIN_KERNEL_MAJOR/MINOR | 5.8 | Minimum kernel version |
| SENSITIVE_READ_PATHS | /etc/shadow, etc. | Files that trigger D002 |
| SHELL_BINARIES | sh, bash, etc. | Binaries that count as "shells" |
## Performance Considerations
**Ring buffer sizing**: 256KB is enough for typical workloads. Under extreme syscall rates (>100K/sec), events may be dropped when `ringbuf_reserve` returns NULL. Increase `RING_BUFFER_BYTES` for high-throughput environments.
**Event enrichment**: Reading `/proc/<pid>/comm` for every event adds latency. The `--no-enrich` flag disables this for high-volume scenarios.
**Username caching**: UID-to-username resolution uses a dict cache to avoid repeated `pwd.getpwuid()` calls.
**Detection engine**: Stateless rules are O(1) per event. Stateful rules scan the deque, which is bounded at 64 entries, so worst case is O(64) comparisons.
## Design Decisions
### Why Python, not Go or Rust?
BCC has mature, well-documented Python bindings. Go bindings exist (via cilium/ebpf) but use libbpf, not BCC. Rust has libbpf-rs. For a beginner project focused on teaching eBPF concepts, Python lets readers focus on the eBPF and security concepts rather than language complexity.
### Why one ring buffer, not per-tracer?
Each BPF program gets its own `BPF_RINGBUF_OUTPUT`, but they all use the same struct layout and the same Python callback. This keeps the callback logic simple. The alternative (per-tracer callbacks with per-tracer structs) would require five separate parsing paths.
### Why tracepoints, not raw_tracepoints?
Raw tracepoints provide a `bpf_raw_tp_args` struct with fewer abstractions. They're slightly faster but harder to work with, you need to manually cast arguments. Standard tracepoints provide `args->` access with named fields, which is much more readable for a learning project.
### Why Typer for CLI?
Consistency with other projects in the repository. Typer provides automatic help generation, type validation, and shell completion with minimal code.
## Extensibility
### Adding a New Syscall
1. Add the event type to `EventType` enum in `config.py`
2. Add it to `EVENT_TYPE_CATEGORIES`
3. Write a `TRACEPOINT_PROBE` in the appropriate `.c` file (or create a new one)
4. If it needs a new detection rule, add to `DETECTION_RULES` and implement in `detector.py`
### Adding a New Detection Rule
1. Add a `DetectionRule` entry to `DETECTION_RULES` in `config.py`
2. Implement the check in `_check_stateless()` or `_check_stateful()` in `detector.py`
3. Add a test in `test_detector.py`
### Adding a New Output Format
1. Create a new renderer class in `renderer.py` with a `render(event)` method
2. Add the format name to the `OutputFormat` literal type in `config.py`
3. Handle it in `create_renderer()`
## Limitations
- **IPv6**: Network tracer only parses IPv4 (`sockaddr_in`). IPv6 support would require handling `sockaddr_in6` and a 128-bit address field.
- **Container awareness**: No container ID or namespace detection. Adding this would require reading `/proc/<pid>/cgroup` or using BPF helpers for namespace IDs.
- **Argument capture**: Only the first argument (filename) is captured for execve. Full argv capture requires reading the pointer array, which is complex in eBPF due to verifier constraints.
- **File descriptor tracking**: The tool doesn't track fd-to-file mappings, so it can't correlate a `connect()` fd with a subsequent `dup2()` call.
- **No persistence**: Events are not stored. For historical analysis, pipe JSON output to a file or a log aggregation system.
## Comparison with Production Tools
| Feature | This Tool | Falco | Tetragon | Tracee |
|---------|-----------|-------|----------|--------|
| eBPF backend | BCC (Python) | libs (C) | libbpf (Go) | libbpf (Go) |
| Syscall coverage | 14 | 50+ | 30+ | 40+ |
| Detection rules | 10 | 100+ | Policy-based | 70+ |
| Enforcement | Detect only | Detect only | Detect + block | Detect only |
| Container awareness | No | Yes | Yes | Yes |
| Memory usage | ~80MB | ~50MB | ~30MB | ~60MB |
| Production ready | No (learning) | Yes | Yes | Yes |
This tool is a learning resource. It teaches the same fundamentals that power Falco and Tetragon, but at a scale where every line of code is readable and understandable.

View File

@ -0,0 +1,487 @@
# Implementation - Code Walkthrough
## File Structure
```
src/
├── __init__.py
├── main.py # 140 lines - CLI orchestration
├── config.py # 180 lines - Constants and rule definitions
├── loader.py # 130 lines - BCC loading and ring buffer setup
├── processor.py # 190 lines - Event parsing and enrichment
├── detector.py # 280 lines - Detection engine
├── renderer.py # 250 lines - Output formatting
└── ebpf/
├── __init__.py
├── process_tracer.c # 70 lines - execve, clone
├── file_tracer.c # 80 lines - openat, unlinkat, renameat2
├── network_tracer.c # 100 lines - connect, accept4, bind, listen
├── privilege_tracer.c # 80 lines - setuid, setgid
└── system_tracer.c # 80 lines - ptrace, mount, init_module
```
## Building the eBPF Programs
### The Event Struct
Every eBPF program shares the same struct layout. Here's the definition from `process_tracer.c`:
```c
struct event {
u64 timestamp_ns;
u32 pid;
u32 ppid;
u32 uid;
u32 gid;
u32 event_type;
u32 ret_val;
char comm[TASK_COMM_LEN];
char filename[FILENAME_LEN];
u32 addr_v4;
u16 port;
u16 protocol;
u32 target_uid;
u32 target_gid;
u32 ptrace_request;
u32 target_pid;
};
```
This struct is duplicated in each `.c` file because BCC compiles each file independently, there's no shared header mechanism in BCC's compilation model. The Python side mirrors this with a `ctypes.Structure` in `processor.py`.
Key sizing decisions:
- `comm` is `TASK_COMM_LEN` (16 bytes), the kernel's maximum process name length
- `filename` is 256 bytes, enough for most paths without hitting the 512-byte stack limit
- Network fields use `u32` for IPv4 and `u16` for port, matching `sockaddr_in` layout
### Tracepoint Attachment
The `TRACEPOINT_PROBE` macro is BCC syntactic sugar. When you write:
```c
TRACEPOINT_PROBE(syscalls, sys_enter_execve) {
// args->filename gives you the first argument
}
```
BCC generates the attachment code. The `args` struct is auto-generated from the tracepoint format file at `/sys/kernel/debug/tracing/events/syscalls/sys_enter_execve/format`. You can inspect it:
```bash
cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_execve/format
```
### Ring Buffer Usage
The reserve/submit pattern avoids unnecessary memory copies:
```c
BPF_RINGBUF_OUTPUT(events, 1 << 18);
TRACEPOINT_PROBE(syscalls, sys_enter_execve) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
// Fill the struct directly in ring buffer memory
e->timestamp_ns = bpf_ktime_get_ns();
e->pid = bpf_get_current_pid_tgid() >> 32;
// ...
events.ringbuf_submit(e, 0);
return 0;
}
```
If `ringbuf_reserve` returns NULL, the buffer is full. The program returns 0 (required by the verifier) and the event is silently dropped. This is a deliberate design choice: dropping events is better than blocking the syscall or crashing.
### Reading Process Context
Getting the current process's parent PID requires reading from `task_struct`:
```c
struct task_struct *task =
(struct task_struct *)bpf_get_current_task();
bpf_probe_read_kernel(
&e->ppid, sizeof(e->ppid),
&task->real_parent->tgid
);
```
`bpf_get_current_task()` returns the current `task_struct` pointer. We can't dereference it directly (verifier would reject it), so we use `bpf_probe_read_kernel()` to safely copy the parent's tgid.
### Network Address Parsing
The network tracer needs to extract IP and port from `sockaddr_in`:
```c
static __always_inline int parse_sockaddr(
struct event *e, const void *uaddr
) {
struct sockaddr_in sa = {};
bpf_probe_read_user(&sa, sizeof(sa), uaddr);
if (sa.sin_family == AF_INET) {
e->addr_v4 = sa.sin_addr.s_addr;
e->port = __builtin_bswap16(sa.sin_port);
e->protocol = AF_INET;
}
return 0;
}
```
The address is in network byte order (big-endian), so we use `__builtin_bswap16` to convert the port to host byte order. The IPv4 address stays in network order and gets converted to dotted notation in Python.
## Building the Python Loader
### BCC Compilation
`loader.py` reads each `.c` file and passes it to BCC:
```python
from bcc import BPF
c_text = src_path.read_text()
bpf = BPF(text=c_text)
bpf["events"].open_ring_buffer(self._callback)
```
BCC compiles the C code using Clang/LLVM at runtime. If there's a syntax error in the C code, it fails here with a compilation error. The compiled eBPF bytecode is automatically loaded into the kernel and attached to the tracepoints declared via `TRACEPOINT_PROBE`.
### Signal Handling
Clean shutdown is critical. eBPF programs stay attached to the kernel until explicitly detached. If the Python process dies without cleanup, the programs keep running (wasting kernel resources) until the BPF objects are garbage collected.
```python
def _handle_stop(signum, frame):
self._running = False
signal.signal(signal.SIGINT, _handle_stop)
signal.signal(signal.SIGTERM, _handle_stop)
try:
while self._running:
for bpf in self._bpf_objects:
bpf.ring_buffer_poll(timeout=100)
finally:
self.cleanup()
```
The 100ms poll timeout means the tool checks for shutdown every 100ms. This is a good balance between responsiveness (Ctrl+C works quickly) and CPU usage (not spinning in a tight loop).
## Building the Event Processor
### ctypes Struct Mapping
The `RawEvent` struct mirrors the C layout exactly:
```python
class RawEvent(ctypes.Structure):
_fields_ = [
("timestamp_ns", ctypes.c_uint64),
("pid", ctypes.c_uint32),
("ppid", ctypes.c_uint32),
("uid", ctypes.c_uint32),
("gid", ctypes.c_uint32),
("event_type", ctypes.c_uint32),
("ret_val", ctypes.c_uint32),
("comm", ctypes.c_char * TASK_COMM_LEN),
("filename", ctypes.c_char * MAX_FILENAME_LEN),
("addr_v4", ctypes.c_uint32),
("port", ctypes.c_uint16),
("protocol", ctypes.c_uint16),
("target_uid", ctypes.c_uint32),
("target_gid", ctypes.c_uint32),
("ptrace_request", ctypes.c_uint32),
("target_pid", ctypes.c_uint32),
]
```
Field order and types must match exactly. A mismatch means the Python side reads garbage. The ring buffer callback casts the raw pointer:
```python
raw = ctypes.cast(
data, ctypes.POINTER(RawEvent)
).contents
```
### Timestamp Conversion
Kernel timestamps from `bpf_ktime_get_ns()` are monotonic nanoseconds since boot, not wall clock time. To convert:
```python
def _boot_time_ns():
for line in Path("/proc/stat").read_text().splitlines():
if line.startswith("btime"):
return int(line.split()[1]) * 1_000_000_000
return 0
_BOOT_NS = _boot_time_ns()
def _ktime_to_datetime(ktime_ns):
epoch_ns = _BOOT_NS + ktime_ns
return datetime.fromtimestamp(
epoch_ns / 1_000_000_000, tz=timezone.utc
)
```
`btime` in `/proc/stat` gives the boot time in epoch seconds. Add the kernel nanoseconds to get the wall clock time.
### IPv4 Conversion
The kernel stores IPv4 addresses in network byte order (big endian). Converting to dotted notation:
```python
def _ipv4_to_str(addr):
if addr == 0:
return ""
return ".".join(
str((addr >> (i * 8)) & 0xFF)
for i in range(4)
)
```
For example, `0x0100007F` becomes `127.0.0.1` (byte 0 = 127, byte 1 = 0, byte 2 = 0, byte 3 = 1).
## Building the Detection Engine
### Stateless Rules
Each stateless rule checks a single event against a pattern. The implementation is a series of conditional checks in `_check_stateless()`:
```python
if event.event_type == "setuid":
if event.target_uid == 0 and event.uid != 0:
rule = DETECTION_RULES["D001"]
return Detection(
rule_id=rule.rule_id,
name=rule.name,
severity=rule.severity,
mitre_id=rule.mitre_id,
description=rule.description,
)
```
The rules are data-driven. `DETECTION_RULES` in `config.py` holds the metadata (ID, name, severity, MITRE mapping). The detection engine only contains the matching logic.
### File Path Matching
File-based detections use prefix matching against curated path lists:
```python
SENSITIVE_READ_PATHS = (
"/etc/shadow",
"/etc/gshadow",
"/etc/sudoers",
"/etc/master.passwd",
)
def _path_matches(filepath, patterns):
for pattern in patterns:
if filepath.startswith(pattern):
return True
return False
```
Using `startswith` rather than exact match catches paths like `/etc/shadow-` (backup) and `/etc/sudoers.d/custom`.
### Write Detection via Flags
The `openat` syscall's `flags` argument tells us if the file is opened for reading or writing. The eBPF program stores flags in `ret_val`:
```python
O_WRONLY = 1
O_RDWR = 2
O_TRUNC = 512
def _is_write_flags(flags):
return bool(flags & (O_WRONLY | O_RDWR))
```
This distinguishes reading a cron file (normal) from writing to one (persistence attempt).
### Stateful Correlation
The reverse shell detection maintains a deque per PID:
```python
def _check_stateful(self, event):
if event.event_type != "execve":
return None
if event.comm not in SHELL_BINARIES:
return None
hist = self._get_history(event.pid)
has_connect = any(
e.event_type == "connect" for e in hist
)
if not has_connect:
ppid_hist = self._history.get(event.ppid)
if ppid_hist:
has_connect = any(
e.event_type == "connect"
for e in ppid_hist
)
if has_connect:
return Detection(...)
```
The parent PID check handles the case where a process does `connect()` then `fork()` + `execve()`. The shell runs as a child process, so the connect event is in the parent's history.
## Building the Output Renderer
### Live Mode with Rich
```python
class LiveRenderer:
def render(self, event):
ts = event.timestamp.strftime("%H:%M:%S")
color = SEVERITY_COLORS.get(
event.severity, "white"
)
sev = Text(f"{event.severity:8s}", style=color)
# ... build line with Rich Text objects
self._console.print(line)
```
Rich's `Text` class supports per-segment styling. CRITICAL events render in bold red, MEDIUM in yellow, LOW in cyan. Detection names appear in bold magenta.
### JSON Mode
```python
class JsonRenderer:
def render(self, event):
d = _event_to_dict(event)
self._stream.write(json.dumps(d) + "\n")
self._stream.flush()
```
One JSON object per line (JSONL format). `flush()` after each event ensures real-time output when piping to other tools.
## Testing Strategy
### Unit Tests (no root required)
Tests use a `make_event` fixture that creates `TracerEvent` instances without eBPF:
```python
@pytest.fixture()
def make_event():
def _make(event_type="execve", pid=1000, ...):
return TracerEvent(
timestamp=datetime.now(tz=timezone.utc),
event_type=event_type,
pid=pid,
...
)
return _make
```
This lets us test detection rules, filtering, and rendering purely in Python:
```python
def test_setuid_zero_by_nonroot(self, make_event):
engine = DetectionEngine()
event = make_event(
event_type="setuid", uid=1000, target_uid=0,
)
result = engine.evaluate(event)
assert result.detection == "Privilege Escalation"
assert result.severity == "CRITICAL"
```
### What's Not Tested
Integration tests (loading eBPF programs, tracing real syscalls) require root and a compatible kernel. These can't run in CI. The `@pytest.mark.integration` marker separates them, and `just test` excludes them by default.
## Common Pitfalls
### Pitfall: ctypes Field Order
If the `_fields_` order in `RawEvent` doesn't match the C struct, every field after the mismatch reads wrong data. The symptom is garbage values for seemingly random fields. Always verify field order matches exactly.
### Pitfall: String Decoding
Kernel strings are null-terminated byte arrays. If you forget to split on `\x00`, you'll get trailing garbage bytes in Python:
```python
# Wrong: raw.comm.decode() might include garbage
# Right: split on null first
raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace")
```
### Pitfall: Network Byte Order
IPv4 addresses and ports come from the kernel in network byte order (big-endian). Ports need `__builtin_bswap16` in C or manual byte swapping in Python. IPv4 addresses can be decomposed byte-by-byte.
### Pitfall: BPF Stack Overflow
The eBPF stack is 512 bytes. The `struct event` alone is ~324 bytes. If you add local variables, you can exceed the limit. The reserve/submit pattern avoids this by writing directly to ring buffer memory instead of using stack-allocated structs.
## Code Organization
### Why No Shared C Header?
BCC compiles each `.c` file independently. There's no `#include "common.h"` mechanism. Each file defines its own copy of `struct event`. This is redundant but matches how BCC works in practice.
### Why config.py Instead of YAML/JSON?
Python constants are type-checked by mypy. They're importable. They don't need a parser. For a tool this size, a config file format adds complexity without benefit.
### Why Dataclass Instead of Pydantic?
`TracerEvent` is a simple data container created thousands of times per second. Pydantic's validation overhead isn't justified when the data source is a trusted kernel ring buffer. Standard `dataclass` is lighter and faster.
## Extending the Code
### Adding a New Syscall Tracer
To trace `mprotect` (memory protection changes, useful for detecting JIT spray attacks):
1. Add `MPROTECT = 15` to `EventType` in `config.py`
2. Add the category mapping: `EventType.MPROTECT: "system"`
3. Add a `TRACEPOINT_PROBE` to `system_tracer.c`:
```c
TRACEPOINT_PROBE(syscalls, sys_enter_mprotect) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e) return 0;
fill_base(e, 15);
e->ret_val = args->prot;
events.ringbuf_submit(e, 0);
return 0;
}
```
4. Add a detection rule if `prot` includes `PROT_EXEC` on a previously non-executable region.
## Dependencies
| Package | Purpose | Why This One |
|---------|---------|-------------|
| typer | CLI framework | Consistent with repo, auto-help generation |
| rich | Terminal formatting | Color output, tables, text styling |
| bcc | eBPF compilation and loading | Only mature Python eBPF framework |
| pytest | Testing | Standard Python testing framework |
| ruff | Linting | Fast, comprehensive, replaces flake8 |
| mypy | Type checking | Static analysis for Python |
| yapf | Formatting | Repo standard |
BCC is a system package, not pip-installable. Install via `apt install python3-bpfcc` (Debian) or `dnf install python3-bcc` (Fedora).
## Build and Deploy
```bash
# Full setup
./install.sh
# Development
uv sync # install Python deps
just lint # ruff + mypy
just format # yapf formatting
just test # unit tests (no root)
sudo uv run ebpf-tracer # run the tool
```
The tool runs in-place, there's no compilation step for the Python code. The eBPF C programs are compiled by BCC at runtime when the tool starts.

View File

@ -0,0 +1,248 @@
# Challenges - Extend the eBPF Security Tracer
## Easy Challenges
### 1. Add IPv6 Support
**What to build**: Extend the network tracer to parse `sockaddr_in6` and display IPv6 addresses.
**Why it's useful**: Many modern services and cloud environments use IPv6. Attackers can use IPv6 to bypass IPv4-only monitoring.
**What you'll learn**: IPv6 address structure, handling multiple address families in eBPF, expanding the event struct.
**Hints**:
- Add a `u8 addr_v6[16]` field to the event struct
- Check `sa.sin_family == AF_INET6` in `parse_sockaddr`
- Use Python's `ipaddress.IPv6Address(bytes(addr_v6))` for conversion
- Don't forget to update the ctypes `RawEvent` definition
**Test it works**: Run `curl -6 http://ipv6.google.com` while tracing and verify the IPv6 address appears in output.
### 2. Add Process Ancestry Chain
**What to build**: For each event, walk up the process tree (via /proc/<pid>/status) to show the full ancestry: `bash -> python -> curl`.
**Why it's useful**: Knowing that a suspicious `connect` came from `systemd -> sshd -> bash -> python -> nc` tells a much richer story than just "nc connected somewhere."
**What you'll learn**: /proc filesystem, process tree walking, performance tradeoffs of enrichment.
**Hints**:
- Read `/proc/<pid>/status` and parse the `PPid:` line
- Walk up until PID 1 or a read error
- Cache results aggressively, process trees don't change often
- Consider a max depth (8-10) to avoid pathological cases
**Test it works**: Run `bash -c "python3 -c 'import os; os.system(\"ls\")'` and verify the ancestry chain appears.
### 3. Add an Event Counter Summary
**What to build**: When the tool exits (Ctrl+C), print a summary table showing total events by type, total detections by severity, and top 10 processes by event count.
**Why it's useful**: After a tracing session, you want a quick overview of what happened without scrolling through thousands of events.
**What you'll learn**: Data aggregation, Rich table formatting, clean shutdown patterns.
**Hints**:
- Add counters to the event processing pipeline (use `collections.Counter`)
- Register a cleanup function that prints the summary
- The `TableRenderer` already shows how to use Rich tables
**Test it works**: Run the tracer for 30 seconds on a busy system, then Ctrl+C and verify the summary appears.
## Intermediate Challenges
### 4. Add Container-Aware Detection
**What to build**: Detect whether events originate from inside a container and include the container ID in the output. Flag kernel module loads and mount operations from containers as CRITICAL.
**Why it's useful**: Container escapes are a major attack vector in Kubernetes. Detecting `mount` or `init_module` from inside a container namespace is a strong indicator of an escape attempt.
**What you'll learn**: Linux namespaces, cgroups, container runtime detection, how containers are just processes with extra isolation.
**Hints**:
- Read `/proc/<pid>/cgroup` to detect container membership
- Docker containers have cgroup paths like `/docker/<container_id>`
- Kubernetes pods have paths like `/kubepods/pod<uid>/<container_id>`
- PID 1 inside a container maps to a regular PID on the host
- Add a `container_id` field to `TracerEvent`
**Test it works**: Run `docker run --rm alpine sh -c "ls /etc"` while tracing and verify the container ID appears. Test that `mount` from a container triggers a CRITICAL detection.
### 5. Add Rate-Based Anomaly Detection
**What to build**: Detect abnormal syscall rates. If a process makes more than N `openat` calls in T seconds (e.g., 100 opens in 5 seconds), flag it as "Rapid File Scanning."
**Why it's useful**: Automated tools scanning for credentials, sensitive files, or exploitable configurations generate distinctive patterns of rapid file access that normal usage doesn't produce.
**What you'll learn**: Sliding window rate calculation, threshold-based anomaly detection, tuning false positive rates.
**Hints**:
- Use the existing per-PID deque in the detection engine
- Count events of each type in the window
- Start with high thresholds to avoid noise, then tune down
- Consider different thresholds per event type (openat is naturally high-volume, ptrace is not)
- Add a `D011` rule to `DETECTION_RULES`
**Test it works**: Write a script that opens 200 files in a loop and verify the detection triggers. Verify that normal `ls` of a large directory does not trigger it.
### 6. Add Syslog/JSON-over-UDP Output
**What to build**: Add an output mode that sends events to a remote syslog server or as JSON over UDP.
**Why it's useful**: In production, you'd feed eBPF events into a SIEM (Splunk, Elastic, Wazuh). UDP/syslog is the simplest integration point.
**What you'll learn**: Network programming in Python, syslog protocol (RFC 5424), structured logging for SIEM integration.
**Hints**:
- `socket.socket(socket.AF_INET, socket.SOCK_DGRAM)` for UDP
- Syslog format: `<priority>VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID MSG`
- Map severity levels to syslog priorities (CRITICAL -> LOG_CRIT, etc.)
- Add `--syslog host:port` CLI option
**Test it works**: Run `nc -ul 1514` in one terminal, start the tracer with `--syslog localhost:1514`, and verify events arrive.
## Advanced Challenges
### 7. Add eBPF-Level Filtering
**What to build**: Move PID and comm filtering into the eBPF programs so filtered events never reach userspace. Currently, all events flow through the ring buffer and get filtered in Python.
**Why it's useful**: On a busy server generating 50K+ events per second, userspace filtering wastes ring buffer bandwidth. eBPF-level filtering reduces overhead by 10-100x for filtered workloads.
**What you'll learn**: BPF hash maps for configuration, passing filter state from Python to eBPF, verifier-safe conditional logic.
**Hints**:
- Use `BPF_HASH(pid_filter, u32, u32)` as a set of PIDs to include
- Populate the map from Python: `b["pid_filter"][ctypes.c_uint32(pid)] = ctypes.c_uint32(1)`
- In the eBPF program, check: `if (pid_filter.lookup(&pid) == NULL) return 0;`
- For comm filtering, use `BPF_HASH(comm_filter, char[16], u32)`
- An empty filter map means "trace all"
**Test it works**: Benchmark event rate with and without eBPF-level filtering on a process spawning 1000 child processes per second. Measure CPU usage difference.
### 8. Build a Real-Time Dashboard
**What to build**: A terminal-based dashboard using Rich's Live display that shows: event rate graph, active detections, top processes, and a scrolling event log, all updating in real time.
**Why it's useful**: Operational security monitoring needs at-a-glance visibility. A dashboard lets you watch system behavior during incident response without reading individual log lines.
**What you'll learn**: Rich Live and Layout for TUI design, concurrent data aggregation, refresh rate management.
**Hints**:
- Use `rich.live.Live` with `rich.layout.Layout` for multi-panel display
- Update every 500ms (2 FPS is enough for human readability)
- Track event rate with a 1-second rolling window
- Use `rich.panel.Panel` for each section
- Consider `rich.progress.SparklineColumn` for rate visualization
**Test it works**: Run the dashboard on a system under load (e.g., `stress-ng --cpu 4 --io 4`) and verify all panels update correctly.
## Expert Challenges
### 9. Build a Detection Rule DSL
**What to build**: Replace the hardcoded detection logic in `detector.py` with a rule engine that loads detection rules from YAML files, similar to Falco's rule format:
```yaml
- rule: Reverse Shell Detected
condition:
sequence:
- event_type: connect
within: 10s
- event_type: execve
comm_in: [sh, bash, dash, zsh]
group_by: pid
severity: CRITICAL
mitre: T1059.004
description: Shell execution following outbound connection
```
**Why it's useful**: Hardcoded detection rules require code changes and redeployment. A DSL lets security teams write and modify rules without touching Python code, which is how Falco, Sigma, and YARA work.
**What you'll learn**: Rule engine design, YAML schema validation, temporal pattern matching, DSL design principles.
**Hints**:
- Start with stateless rules (single event matching) before tackling sequences
- Use Pydantic for rule schema validation
- Support operators: `eq`, `in`, `startswith`, `regex`, `gt`, `lt`
- For sequence rules, reuse the existing deque-based correlation but make it configurable
- Add `--rules-dir` CLI option to load rules from a directory
- Consider rule priorities (first match vs best match)
**Test it works**: Port all 10 existing detection rules to YAML. Verify all existing tests still pass against the YAML-loaded rules. Add a custom rule and verify it detects correctly.
## Mix and Match
- **Container + Rate Anomaly**: Detect rapid file scanning inside containers (strong indicator of container reconnaissance before an escape attempt)
- **IPv6 + Syslog**: Full-stack monitoring with IPv6 support piped to a SIEM
- **Dashboard + eBPF Filtering**: High-performance dashboard that only shows filtered events
## Real World Integration
- **Wazuh**: Pipe JSON output to Wazuh's `ossec.log` or use the API for real-time event ingestion
- **Elastic SIEM**: Send JSONL output to Filebeat, which ships it to Elasticsearch
- **Grafana/Loki**: Use promtail to ship events, build dashboards for event rates and detection counts
- **Slack/PagerDuty**: Add a webhook renderer that sends CRITICAL detections to Slack or triggers PagerDuty incidents
## Performance Challenges
### Benchmark and Optimize
1. Generate load with `stress-ng --syscall 0 --timeout 60s`
2. Measure events/second throughput
3. Profile with `py-spy` to find Python bottlenecks
4. Target: handle 50K+ events/second without dropping events
### Ring Buffer Tuning
1. Start with 256KB ring buffer
2. Under load, check drop rate (add a lost event callback)
3. Experiment with 512KB, 1MB, 4MB buffers
4. Find the minimum buffer size that achieves zero drops for your workload
## Security Challenges
### Add File Integrity Monitoring
Monitor writes to critical system files (`/etc/passwd`, `/etc/sudoers`, `/etc/ssh/sshd_config`) and alert on any modification. This is what tools like AIDE and Tripwire do, but in real time.
### Add Network Allowlist/Denylist
Maintain a list of expected outbound connections per process. Alert when a process connects to an IP or port not in its allowlist. Start with a learning mode that auto-generates the allowlist.
### Add Anti-Evasion Detection
Detect processes that try to evade tracing: renaming themselves to look like system processes, forking rapidly to confuse PID-based tracking, or using `prctl(PR_SET_NAME)` to change their comm.
## Contribution Ideas
- Port the eBPF programs from BCC to libbpf for production readiness
- Add eBPF LSM hooks for enforcement (block, not just detect)
- Build a web UI with WebSocket-based real-time event streaming
- Add Sigma rule format support (industry standard detection rules)
- Create systemd unit file for running as a daemon
## Challenge Completion
- [ ] Easy 1: IPv6 Support
- [ ] Easy 2: Process Ancestry Chain
- [ ] Easy 3: Event Counter Summary
- [ ] Intermediate 4: Container-Aware Detection
- [ ] Intermediate 5: Rate-Based Anomaly Detection
- [ ] Intermediate 6: Syslog/UDP Output
- [ ] Advanced 7: eBPF-Level Filtering
- [ ] Advanced 8: Real-Time Dashboard
- [ ] Expert 9: Detection Rule DSL
## Getting Help
**Debugging eBPF programs**: Add `bpf_trace_printk("debug: %d\n", value)` to your C code and read output with `sudo cat /sys/kernel/debug/tracing/trace_pipe`. This is the printf-debugging equivalent for eBPF.
**Verifier errors**: The eBPF verifier prints cryptic messages. Common causes: unbounded loop, memory access without bounds check, stack overflow (>512 bytes). Reduce struct sizes or use BPF maps for large data.
**BCC issues**: If BCC fails to compile, check that kernel headers match your running kernel: `uname -r` should match a directory in `/lib/modules/`.
**Community resources**:
- [iovisor/bcc GitHub Issues](https://github.com/iovisor/bcc/issues) - BCC-specific questions
- [eBPF Slack](https://ebpf.io/slack) - Community chat
- [Brendan Gregg's Blog](https://www.brendangregg.com/blog/) - eBPF performance analysis

View File

@ -0,0 +1,65 @@
# ©AngelaMos | 2026
# pyproject.toml
[project]
name = "ebpf-security-tracer"
version = "1.0.0"
description = "Real-time syscall tracing tool using eBPF for security observability"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"typer>=0.21.1",
"rich>=14.0.0",
]
[project.scripts]
ebpf-tracer = "src.main:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src"]
[tool.ruff]
line-length = 75
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "C4"]
ignore = ["E501"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
markers = [
"integration: requires root and eBPF kernel support",
]
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_ignores = false
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "bcc.*"
ignore_missing_imports = true
ignore_errors = true
[dependency-groups]
dev = [
"mypy>=1.19.0",
"pytest>=9.0.0",
"ruff>=0.14.0",
"yapf>=0.43.0",
]
[tool.yapfignore]
ignore_patterns = [
".venv/",
"venv/",
]

View File

@ -0,0 +1,4 @@
"""
©AngelaMos | 2026
__init__.py
"""

View File

@ -0,0 +1,242 @@
"""
©AngelaMos | 2026
config.py
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum
from pathlib import Path
from typing import Literal
PACKAGE_DIR = Path(__file__).parent
EBPF_DIR = PACKAGE_DIR / "ebpf"
TASK_COMM_LEN = 16
MAX_FILENAME_LEN = 256
RING_BUFFER_BYTES = 256 * 1024
CORRELATION_WINDOW_SEC = 10
MAX_EVENTS_PER_PID = 64
SWEEP_INTERVAL = 1000
MIN_KERNEL_MAJOR = 5
MIN_KERNEL_MINOR = 8
Severity = Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"]
OutputFormat = Literal["json", "table", "live"]
TracerType = Literal["process", "file", "network", "privilege", "system",
"all"]
SEVERITY_ORDER: dict[str, int] = {
"LOW": 0,
"MEDIUM": 1,
"HIGH": 2,
"CRITICAL": 3,
}
SEVERITY_COLORS: dict[str, str] = {
"LOW": "cyan",
"MEDIUM": "yellow",
"HIGH": "red",
"CRITICAL": "bold red",
}
class EventType(IntEnum):
"""
Numeric identifiers for traced syscall events
"""
EXECVE = 1
CLONE = 2
OPENAT = 3
UNLINKAT = 4
RENAMEAT2 = 5
CONNECT = 6
ACCEPT4 = 7
BIND = 8
LISTEN = 9
SETUID = 10
SETGID = 11
PTRACE = 12
MOUNT = 13
INIT_MODULE = 14
EVENT_TYPE_NAMES: dict[int, str] = {
e.value: e.name.lower()
for e in EventType
}
EVENT_TYPE_CATEGORIES: dict[int, str] = {
EventType.EXECVE: "process",
EventType.CLONE: "process",
EventType.OPENAT: "file",
EventType.UNLINKAT: "file",
EventType.RENAMEAT2: "file",
EventType.CONNECT: "network",
EventType.ACCEPT4: "network",
EventType.BIND: "network",
EventType.LISTEN: "network",
EventType.SETUID: "privilege",
EventType.SETGID: "privilege",
EventType.PTRACE: "system",
EventType.MOUNT: "system",
EventType.INIT_MODULE: "system",
}
SENSITIVE_READ_PATHS: tuple[str, ...] = (
"/etc/shadow",
"/etc/gshadow",
"/etc/sudoers",
"/etc/master.passwd",
)
CREDENTIAL_PATHS: tuple[str, ...] = (
"/.ssh/id_rsa",
"/.ssh/id_ed25519",
"/.ssh/id_ecdsa",
"/.ssh/id_dsa",
"/.ssh/authorized_keys",
"/.aws/credentials",
"/.gnupg/",
)
CREDENTIAL_ACCESS_ALLOWLIST: tuple[str, ...] = (
"sshd",
"ssh",
"ssh-agent",
"ssh-add",
"gpg-agent",
"gpg",
"gpg2",
"gpgsm",
)
PERSISTENCE_CRON_PATHS: tuple[str, ...] = (
"/etc/cron",
"/var/spool/cron",
"/etc/crontab",
)
PERSISTENCE_SYSTEMD_PATHS: tuple[str, ...] = (
"/etc/systemd/system/",
"/lib/systemd/system/",
"/usr/lib/systemd/system/",
)
LOG_PATHS: tuple[str, ...] = (
"/var/log/",
"/var/log/syslog",
"/var/log/auth.log",
"/var/log/kern.log",
)
SHELL_BINARIES: tuple[str, ...] = (
"sh",
"bash",
"dash",
"zsh",
"csh",
"tcsh",
"fish",
"ksh",
)
PTRACE_ATTACH = 16
PTRACE_SEIZE = 16902
PTRACE_SETREGS = 13
@dataclass(frozen=True)
class DetectionRule:
"""
Metadata for a single detection rule
"""
rule_id: str
name: str
severity: Severity
mitre_id: str
description: str
DETECTION_RULES: dict[str, DetectionRule] = {
"D001":
DetectionRule(
rule_id="D001",
name="Privilege Escalation",
severity="CRITICAL",
mitre_id="T1548",
description="setuid(0) called by non-root process",
),
"D002":
DetectionRule(
rule_id="D002",
name="Sensitive File Read",
severity="MEDIUM",
mitre_id="T1003.008",
description=("Non-standard process reading credential files"),
),
"D003":
DetectionRule(
rule_id="D003",
name="SSH Key Access",
severity="MEDIUM",
mitre_id="T1552.004",
description="Process accessing SSH key material",
),
"D004":
DetectionRule(
rule_id="D004",
name="Process Injection",
severity="MEDIUM",
mitre_id="T1055.008",
description="ptrace attach to another process",
),
"D005":
DetectionRule(
rule_id="D005",
name="Kernel Module Load",
severity="HIGH",
mitre_id="T1547.006",
description="Kernel module loaded via init_module",
),
"D006":
DetectionRule(
rule_id="D006",
name="Reverse Shell",
severity="CRITICAL",
mitre_id="T1059.004",
description=("Shell execution following network connection"),
),
"D007":
DetectionRule(
rule_id="D007",
name="Persistence via Cron",
severity="MEDIUM",
mitre_id="T1053.003",
description="Write operation to cron directories",
),
"D008":
DetectionRule(
rule_id="D008",
name="Persistence via Systemd",
severity="MEDIUM",
mitre_id="T1543.002",
description=("Write operation to systemd unit directories"),
),
"D009":
DetectionRule(
rule_id="D009",
name="Log Tampering",
severity="MEDIUM",
mitre_id="T1070.002",
description="Deletion or truncation of log files",
),
"D010":
DetectionRule(
rule_id="D010",
name="Suspicious Mount",
severity="HIGH",
mitre_id="T1611",
description=("Filesystem mount operation detected"),
),
}

View File

@ -0,0 +1,243 @@
"""
©AngelaMos | 2026
detector.py
"""
from __future__ import annotations
import time
from collections import deque
from .config import (
CORRELATION_WINDOW_SEC,
CREDENTIAL_ACCESS_ALLOWLIST,
CREDENTIAL_PATHS,
DETECTION_RULES,
LOG_PATHS,
MAX_EVENTS_PER_PID,
PERSISTENCE_CRON_PATHS,
PERSISTENCE_SYSTEMD_PATHS,
PTRACE_ATTACH,
PTRACE_SEIZE,
PTRACE_SETREGS,
SENSITIVE_READ_PATHS,
SHELL_BINARIES,
SWEEP_INTERVAL,
DetectionRule,
)
from .processor import TracerEvent
def _apply_detection(
event: TracerEvent,
rule: DetectionRule,
) -> TracerEvent:
"""
Stamp a detection onto an event
"""
event.severity = rule.severity
event.detection = rule.name
event.detection_id = rule.rule_id
event.mitre_id = rule.mitre_id
return event
def _path_matches(
filepath: str,
patterns: tuple[str, ...],
) -> bool:
"""
Check if a filepath starts with any pattern
"""
for pattern in patterns:
if filepath.startswith(pattern):
return True
return False
def _path_contains(
filepath: str,
patterns: tuple[str, ...],
) -> bool:
"""
Check if a filepath contains any pattern
"""
for pattern in patterns:
if pattern in filepath:
return True
return False
O_WRONLY = 1
O_RDWR = 2
O_TRUNC = 512
O_CREAT = 64
def _is_write_flags(flags: int) -> bool:
"""
Determine if openat flags indicate a write operation
"""
return bool(flags & (O_WRONLY | O_RDWR))
class DetectionEngine:
"""
Evaluates events against detection rules
"""
def __init__(self) -> None:
"""
Initialize event history for correlation
"""
self._history: dict[int, deque[TracerEvent]] = {}
self._event_count = 0
def _get_history(
self,
pid: int,
) -> deque[TracerEvent]:
"""
Get or create the event deque for a PID
"""
if pid not in self._history:
self._history[pid] = deque(maxlen=MAX_EVENTS_PER_PID)
return self._history[pid]
def _prune_history(self, pid: int) -> None:
"""
Remove stale events outside the correlation window
"""
if pid not in self._history:
return
cutoff = time.monotonic() - CORRELATION_WINDOW_SEC
hist = self._history[pid]
while hist and hist[0].extra.get("_mono_time", 0) < cutoff:
hist.popleft()
if not hist:
del self._history[pid]
def _sweep_stale(self) -> None:
"""
Remove all PIDs with only expired events
"""
cutoff = time.monotonic() - CORRELATION_WINDOW_SEC
stale_pids = [
pid for pid, hist in self._history.items()
if not hist or hist[-1].extra.get("_mono_time", 0) < cutoff
]
for pid in stale_pids:
del self._history[pid]
def evaluate(
self,
event: TracerEvent,
) -> TracerEvent:
"""
Run all detection rules against an event
"""
event.extra["_mono_time"] = time.monotonic()
det = self._check_stateless(event)
if det is None:
det = self._check_stateful(event)
hist = self._get_history(event.pid)
hist.append(event)
self._prune_history(event.pid)
self._event_count += 1
if self._event_count % SWEEP_INTERVAL == 0:
self._sweep_stale()
if det is not None:
return _apply_detection(event, det)
return event
def _check_stateless(
self,
event: TracerEvent,
) -> DetectionRule | None:
"""
Check single-event detection rules
"""
if event.event_type == "setuid":
if event.target_uid == 0 and event.uid != 0:
return DETECTION_RULES["D001"]
if event.event_type == "openat":
filename = event.filename
if _path_matches(filename, SENSITIVE_READ_PATHS):
if (event.uid != 0 and not _is_write_flags(event.flags)):
return DETECTION_RULES["D002"]
if _path_contains(filename, CREDENTIAL_PATHS):
if event.comm not in CREDENTIAL_ACCESS_ALLOWLIST:
return DETECTION_RULES["D003"]
if _is_write_flags(event.flags):
if _path_matches(filename, PERSISTENCE_CRON_PATHS):
return DETECTION_RULES["D007"]
if _path_matches(filename, PERSISTENCE_SYSTEMD_PATHS):
return DETECTION_RULES["D008"]
if _path_matches(filename, LOG_PATHS):
if event.flags & O_TRUNC:
return DETECTION_RULES["D009"]
if event.event_type == "unlinkat":
if _path_matches(event.filename, LOG_PATHS):
return DETECTION_RULES["D009"]
if event.event_type == "ptrace":
if event.ptrace_request in (
PTRACE_ATTACH,
PTRACE_SEIZE,
PTRACE_SETREGS,
):
return DETECTION_RULES["D004"]
if event.event_type == "init_module":
return DETECTION_RULES["D005"]
if event.event_type == "mount":
return DETECTION_RULES["D010"]
return None
def _check_stateful(
self,
event: TracerEvent,
) -> DetectionRule | None:
"""
Check multi-event correlation rules
"""
if event.event_type == "execve":
if event.comm not in SHELL_BINARIES:
return None
hist = self._get_history(event.pid)
has_connect = any(e.event_type == "connect" for e in hist)
if not has_connect:
ppid_hist = self._history.get(event.ppid)
if ppid_hist:
has_connect = any(e.event_type == "connect"
for e in ppid_hist)
if has_connect:
return DETECTION_RULES["D006"]
if event.event_type == "connect":
hist = self._get_history(event.pid)
has_shell = any(
e.event_type == "execve" and e.comm in SHELL_BINARIES
for e in hist)
if has_shell:
return DETECTION_RULES["D006"]
return None

View File

@ -0,0 +1,4 @@
"""
©AngelaMos | 2026
__init__.py
"""

View File

@ -0,0 +1,97 @@
// ©AngelaMos | 2026
// file_tracer.c
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#define EVENT_OPENAT 3
#define EVENT_UNLINKAT 4
#define EVENT_RENAMEAT2 5
#define FILENAME_LEN 256
struct event {
u64 timestamp_ns;
u32 pid;
u32 ppid;
u32 uid;
u32 gid;
u32 event_type;
u32 flags;
char comm[TASK_COMM_LEN];
char filename[FILENAME_LEN];
u32 addr_v4;
u16 port;
u16 protocol;
u32 target_uid;
u32 target_gid;
u32 ptrace_request;
u32 target_pid;
};
BPF_RINGBUF_OUTPUT(events, 1 << 18);
static __always_inline void fill_base(struct event *e, u32 etype) {
u64 pid_tgid = bpf_get_current_pid_tgid();
u64 uid_gid = bpf_get_current_uid_gid();
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
e->timestamp_ns = bpf_ktime_get_ns();
e->pid = pid_tgid >> 32;
e->uid = uid_gid & 0xFFFFFFFF;
e->gid = uid_gid >> 32;
e->event_type = etype;
e->flags = 0;
bpf_probe_read_kernel(&e->ppid, sizeof(e->ppid),
&task->real_parent->tgid);
bpf_get_current_comm(&e->comm, sizeof(e->comm));
__builtin_memset(e->filename, 0, sizeof(e->filename));
e->addr_v4 = 0;
e->port = 0;
e->protocol = 0;
e->target_uid = 0;
e->target_gid = 0;
e->ptrace_request = 0;
e->target_pid = 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_openat) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_OPENAT);
bpf_probe_read_user_str(e->filename, sizeof(e->filename),
args->filename);
e->flags = args->flags;
events.ringbuf_submit(e, 0);
return 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_unlinkat) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_UNLINKAT);
bpf_probe_read_user_str(e->filename, sizeof(e->filename),
args->pathname);
events.ringbuf_submit(e, 0);
return 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_renameat2) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_RENAMEAT2);
bpf_probe_read_user_str(e->filename, sizeof(e->filename),
args->newname);
events.ringbuf_submit(e, 0);
return 0;
}

View File

@ -0,0 +1,121 @@
// ©AngelaMos | 2026
// network_tracer.c
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#include <linux/socket.h>
#include <linux/in.h>
#define EVENT_CONNECT 6
#define EVENT_ACCEPT4 7
#define EVENT_BIND 8
#define EVENT_LISTEN 9
#define FILENAME_LEN 256
struct event {
u64 timestamp_ns;
u32 pid;
u32 ppid;
u32 uid;
u32 gid;
u32 event_type;
u32 flags;
char comm[TASK_COMM_LEN];
char filename[FILENAME_LEN];
u32 addr_v4;
u16 port;
u16 protocol;
u32 target_uid;
u32 target_gid;
u32 ptrace_request;
u32 target_pid;
};
BPF_RINGBUF_OUTPUT(events, 1 << 18);
static __always_inline void fill_base(struct event *e, u32 etype) {
u64 pid_tgid = bpf_get_current_pid_tgid();
u64 uid_gid = bpf_get_current_uid_gid();
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
e->timestamp_ns = bpf_ktime_get_ns();
e->pid = pid_tgid >> 32;
e->uid = uid_gid & 0xFFFFFFFF;
e->gid = uid_gid >> 32;
e->event_type = etype;
e->flags = 0;
bpf_probe_read_kernel(&e->ppid, sizeof(e->ppid),
&task->real_parent->tgid);
bpf_get_current_comm(&e->comm, sizeof(e->comm));
__builtin_memset(e->filename, 0, sizeof(e->filename));
e->addr_v4 = 0;
e->port = 0;
e->protocol = 0;
e->target_uid = 0;
e->target_gid = 0;
e->ptrace_request = 0;
e->target_pid = 0;
}
static __always_inline int parse_sockaddr(
struct event *e, const void *uaddr
) {
struct sockaddr_in sa = {};
bpf_probe_read_user(&sa, sizeof(sa), uaddr);
if (sa.sin_family == AF_INET) {
e->addr_v4 = sa.sin_addr.s_addr;
e->port = __builtin_bswap16(sa.sin_port);
e->protocol = AF_INET;
}
return 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_connect) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_CONNECT);
parse_sockaddr(e, args->uservaddr);
events.ringbuf_submit(e, 0);
return 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_accept4) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_ACCEPT4);
events.ringbuf_submit(e, 0);
return 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_bind) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_BIND);
parse_sockaddr(e, args->umyaddr);
events.ringbuf_submit(e, 0);
return 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_listen) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_LISTEN);
events.ringbuf_submit(e, 0);
return 0;
}

View File

@ -0,0 +1,80 @@
// ©AngelaMos | 2026
// privilege_tracer.c
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#define EVENT_SETUID 10
#define EVENT_SETGID 11
#define FILENAME_LEN 256
struct event {
u64 timestamp_ns;
u32 pid;
u32 ppid;
u32 uid;
u32 gid;
u32 event_type;
u32 flags;
char comm[TASK_COMM_LEN];
char filename[FILENAME_LEN];
u32 addr_v4;
u16 port;
u16 protocol;
u32 target_uid;
u32 target_gid;
u32 ptrace_request;
u32 target_pid;
};
BPF_RINGBUF_OUTPUT(events, 1 << 18);
static __always_inline void fill_base(struct event *e, u32 etype) {
u64 pid_tgid = bpf_get_current_pid_tgid();
u64 uid_gid = bpf_get_current_uid_gid();
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
e->timestamp_ns = bpf_ktime_get_ns();
e->pid = pid_tgid >> 32;
e->uid = uid_gid & 0xFFFFFFFF;
e->gid = uid_gid >> 32;
e->event_type = etype;
e->flags = 0;
bpf_probe_read_kernel(&e->ppid, sizeof(e->ppid),
&task->real_parent->tgid);
bpf_get_current_comm(&e->comm, sizeof(e->comm));
__builtin_memset(e->filename, 0, sizeof(e->filename));
e->addr_v4 = 0;
e->port = 0;
e->protocol = 0;
e->target_uid = 0;
e->target_gid = 0;
e->ptrace_request = 0;
e->target_pid = 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_setuid) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_SETUID);
e->target_uid = args->uid;
events.ringbuf_submit(e, 0);
return 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_setgid) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_SETGID);
e->target_gid = args->gid;
events.ringbuf_submit(e, 0);
return 0;
}

View File

@ -0,0 +1,80 @@
// ©AngelaMos | 2026
// process_tracer.c
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#define EVENT_EXECVE 1
#define EVENT_CLONE 2
#define FILENAME_LEN 256
struct event {
u64 timestamp_ns;
u32 pid;
u32 ppid;
u32 uid;
u32 gid;
u32 event_type;
u32 flags;
char comm[TASK_COMM_LEN];
char filename[FILENAME_LEN];
u32 addr_v4;
u16 port;
u16 protocol;
u32 target_uid;
u32 target_gid;
u32 ptrace_request;
u32 target_pid;
};
BPF_RINGBUF_OUTPUT(events, 1 << 18);
static __always_inline void fill_base(struct event *e, u32 etype) {
u64 pid_tgid = bpf_get_current_pid_tgid();
u64 uid_gid = bpf_get_current_uid_gid();
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
e->timestamp_ns = bpf_ktime_get_ns();
e->pid = pid_tgid >> 32;
e->uid = uid_gid & 0xFFFFFFFF;
e->gid = uid_gid >> 32;
e->event_type = etype;
e->flags = 0;
bpf_probe_read_kernel(&e->ppid, sizeof(e->ppid),
&task->real_parent->tgid);
bpf_get_current_comm(&e->comm, sizeof(e->comm));
__builtin_memset(e->filename, 0, sizeof(e->filename));
e->addr_v4 = 0;
e->port = 0;
e->protocol = 0;
e->target_uid = 0;
e->target_gid = 0;
e->ptrace_request = 0;
e->target_pid = 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_execve) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_EXECVE);
bpf_probe_read_user_str(e->filename, sizeof(e->filename),
args->filename);
events.ringbuf_submit(e, 0);
return 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_clone) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_CLONE);
events.ringbuf_submit(e, 0);
return 0;
}

View File

@ -0,0 +1,94 @@
// ©AngelaMos | 2026
// system_tracer.c
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#define EVENT_PTRACE 12
#define EVENT_MOUNT 13
#define EVENT_INIT_MODULE 14
#define FILENAME_LEN 256
struct event {
u64 timestamp_ns;
u32 pid;
u32 ppid;
u32 uid;
u32 gid;
u32 event_type;
u32 flags;
char comm[TASK_COMM_LEN];
char filename[FILENAME_LEN];
u32 addr_v4;
u16 port;
u16 protocol;
u32 target_uid;
u32 target_gid;
u32 ptrace_request;
u32 target_pid;
};
BPF_RINGBUF_OUTPUT(events, 1 << 18);
static __always_inline void fill_base(struct event *e, u32 etype) {
u64 pid_tgid = bpf_get_current_pid_tgid();
u64 uid_gid = bpf_get_current_uid_gid();
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
e->timestamp_ns = bpf_ktime_get_ns();
e->pid = pid_tgid >> 32;
e->uid = uid_gid & 0xFFFFFFFF;
e->gid = uid_gid >> 32;
e->event_type = etype;
e->flags = 0;
bpf_probe_read_kernel(&e->ppid, sizeof(e->ppid),
&task->real_parent->tgid);
bpf_get_current_comm(&e->comm, sizeof(e->comm));
__builtin_memset(e->filename, 0, sizeof(e->filename));
e->addr_v4 = 0;
e->port = 0;
e->protocol = 0;
e->target_uid = 0;
e->target_gid = 0;
e->ptrace_request = 0;
e->target_pid = 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_ptrace) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_PTRACE);
e->ptrace_request = args->request;
e->target_pid = args->pid;
events.ringbuf_submit(e, 0);
return 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_mount) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_MOUNT);
bpf_probe_read_user_str(e->filename, sizeof(e->filename),
args->dev_name);
events.ringbuf_submit(e, 0);
return 0;
}
TRACEPOINT_PROBE(syscalls, sys_enter_init_module) {
struct event *e = events.ringbuf_reserve(sizeof(*e));
if (!e)
return 0;
fill_base(e, EVENT_INIT_MODULE);
events.ringbuf_submit(e, 0);
return 0;
}

View File

@ -0,0 +1,129 @@
"""
©AngelaMos | 2026
loader.py
"""
from __future__ import annotations
import os
import signal
import sys
from collections.abc import Callable
from typing import Any
from .config import (
EBPF_DIR,
MIN_KERNEL_MAJOR,
MIN_KERNEL_MINOR,
TracerType,
)
TRACER_FILES: dict[str, str] = {
"process": "process_tracer.c",
"file": "file_tracer.c",
"network": "network_tracer.c",
"privilege": "privilege_tracer.c",
"system": "system_tracer.c",
}
def check_privileges() -> None:
"""
Verify the process has root privileges
"""
if os.geteuid() != 0:
sys.stderr.write("Error: eBPF tracing requires root privileges.\n"
"Run with: sudo uv run ebpf-tracer\n")
sys.exit(1)
def check_kernel_version() -> None:
"""
Verify the kernel version supports ring buffers
"""
release = os.uname().release
parts = release.split(".")
major = int(parts[0])
minor = int(parts[1].split("-")[0])
if (major < MIN_KERNEL_MAJOR
or (major == MIN_KERNEL_MAJOR and minor < MIN_KERNEL_MINOR)):
sys.stderr.write(f"Error: Kernel {release} detected. "
f"Requires {MIN_KERNEL_MAJOR}."
f"{MIN_KERNEL_MINOR}+ for ring buffer.\n")
sys.exit(1)
def _resolve_tracers(tracer_type: TracerType, ) -> list[str]:
"""
Determine which tracer files to load
"""
if tracer_type == "all":
return list(TRACER_FILES.keys())
return [tracer_type]
class TracerLoader:
"""
Loads and manages eBPF programs via BCC
"""
def __init__(
self,
tracer_type: TracerType,
callback: Callable[..., None],
) -> None:
"""
Initialize the loader with tracer selection
"""
self._bpf_objects: list[Any] = []
self._tracer_type = tracer_type
self._callback = callback
self._running = False
def load(self) -> None:
"""
Compile and load all selected eBPF programs
"""
from bcc import BPF # type: ignore[import-untyped]
tracers = _resolve_tracers(self._tracer_type)
for name in tracers:
filename = TRACER_FILES[name]
src_path = EBPF_DIR / filename
c_text = src_path.read_text()
bpf = BPF(text=c_text)
bpf["events"].open_ring_buffer(self._callback)
self._bpf_objects.append(bpf)
def poll(self) -> None:
"""
Start polling all ring buffers for events
"""
self._running = True
original_sigint = signal.getsignal(signal.SIGINT)
original_sigterm = signal.getsignal(signal.SIGTERM)
def _handle_stop(signum: int, frame: Any) -> None:
self._running = False
signal.signal(signal.SIGINT, _handle_stop)
signal.signal(signal.SIGTERM, _handle_stop)
try:
while self._running:
for bpf in self._bpf_objects:
bpf.ring_buffer_poll(timeout=100)
finally:
signal.signal(signal.SIGINT, original_sigint)
signal.signal(signal.SIGTERM, original_sigterm)
self.cleanup()
def cleanup(self) -> None:
"""
Detach all eBPF programs and free resources
"""
for bpf in self._bpf_objects:
bpf.cleanup()
self._bpf_objects.clear()

View File

@ -0,0 +1,160 @@
"""
©AngelaMos | 2026
main.py
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import typer
from rich.console import Console
from .config import OutputFormat, Severity, TracerType
from .detector import DetectionEngine
from .loader import (
TracerLoader,
check_kernel_version,
check_privileges,
)
from .processor import (
enrich_event,
parse_raw_event,
should_include,
)
from .renderer import (
FileRenderer,
TableRenderer,
create_renderer,
)
app = typer.Typer(
name="ebpf-tracer",
help="Real-time syscall tracing with eBPF",
add_completion=False,
)
console = Console()
VERSION = "1.0.0"
def _version_callback(value: bool) -> None:
"""
Print version and exit
"""
if value:
console.print(f"ebpf-tracer v{VERSION}")
raise typer.Exit()
@app.command()
def trace(
format: OutputFormat = typer.Option(
"live",
"--format",
"-f",
help="Output format",
),
severity: Severity = typer.Option(
"LOW",
"--severity",
"-s",
help="Minimum severity level",
),
pid: int | None = typer.Option(
None,
"--pid",
"-p",
help="Filter by PID",
),
comm: str | None = typer.Option(
None,
"--comm",
"-c",
help="Filter by process name",
),
tracer_type: TracerType = typer.Option(
"all",
"--type",
"-t",
help="Event category filter",
),
no_enrich: bool = typer.Option(
False,
"--no-enrich",
help="Disable /proc enrichment",
),
output: Path | None = typer.Option(
None,
"--output",
"-o",
help="Also write events to file",
),
detections_only: bool = typer.Option(
False,
"--detections",
help="Show only detection alerts",
),
version: bool = typer.Option(
False,
"--version",
callback=_version_callback,
is_eager=True,
help="Show version",
),
) -> None:
"""
Start the eBPF security tracer
"""
check_privileges()
check_kernel_version()
detector = DetectionEngine()
renderer = create_renderer(format)
file_renderer: FileRenderer | None = None
if output is not None:
file_renderer = FileRenderer(output)
def on_event(ctx: Any, data: Any, size: int) -> None:
event = parse_raw_event(ctx, data, size)
if not no_enrich:
event = enrich_event(event)
event = detector.evaluate(event)
if not should_include(
event,
severity,
pid,
comm,
tracer_type,
detections_only,
):
return
renderer.render(event)
if file_renderer is not None:
file_renderer.render(event)
console.print("[bold green]eBPF Security Tracer[/] "
f"v{VERSION}")
console.print(f"Format: {format} | "
f"Min severity: {severity} | "
f"Type: {tracer_type}")
console.print("Press Ctrl+C to stop\n")
loader = TracerLoader(tracer_type, on_event)
try:
loader.load()
loader.poll()
finally:
if isinstance(renderer, TableRenderer):
renderer.finalize()
if file_renderer is not None:
file_renderer.close()
console.print("\n[bold red]Tracer stopped[/]")

View File

@ -0,0 +1,232 @@
"""
©AngelaMos | 2026
processor.py
"""
from __future__ import annotations
import ctypes
import pwd
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .config import (
EVENT_TYPE_CATEGORIES,
EVENT_TYPE_NAMES,
MAX_FILENAME_LEN,
SEVERITY_ORDER,
TASK_COMM_LEN,
Severity,
TracerType,
)
class RawEvent(ctypes.Structure):
"""
Mirrors the C struct event layout from eBPF programs
"""
_fields_ = [
("timestamp_ns", ctypes.c_uint64),
("pid", ctypes.c_uint32),
("ppid", ctypes.c_uint32),
("uid", ctypes.c_uint32),
("gid", ctypes.c_uint32),
("event_type", ctypes.c_uint32),
("flags", ctypes.c_uint32),
("comm", ctypes.c_char * TASK_COMM_LEN),
("filename", ctypes.c_char * MAX_FILENAME_LEN),
("addr_v4", ctypes.c_uint32),
("port", ctypes.c_uint16),
("protocol", ctypes.c_uint16),
("target_uid", ctypes.c_uint32),
("target_gid", ctypes.c_uint32),
("ptrace_request", ctypes.c_uint32),
("target_pid", ctypes.c_uint32),
]
@dataclass
class TracerEvent:
"""
Processed event with enriched metadata
"""
timestamp: datetime
event_type: str
category: str
pid: int
ppid: int
uid: int
gid: int
username: str
comm: str
filename: str
addr_v4: str
port: int
protocol: int
target_uid: int
target_gid: int
ptrace_request: int
target_pid: int
flags: int
severity: Severity = "LOW"
detection: str | None = None
detection_id: str | None = None
mitre_id: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
def _decode_comm(raw: bytes) -> str:
"""
Decode a null-terminated comm field
"""
return raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace")
def _decode_filename(raw: bytes) -> str:
"""
Decode a null-terminated filename field
"""
return raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace")
def _ipv4_to_str(addr: int) -> str:
"""
Convert a 32-bit network-order IPv4 address to string
"""
if addr == 0:
return ""
return ".".join(str((addr >> (i * 8)) & 0xFF) for i in range(4))
_UID_CACHE: dict[int, str] = {}
def _resolve_username(uid: int) -> str:
"""
Resolve UID to username with caching
"""
if uid in _UID_CACHE:
return _UID_CACHE[uid]
try:
name = pwd.getpwuid(uid).pw_name
except KeyError:
name = str(uid)
_UID_CACHE[uid] = name
return name
def _boot_time_ns() -> int:
"""
Read system boot time for timestamp conversion
"""
stat_path = Path("/proc/stat")
if not stat_path.exists():
return 0
for line in stat_path.read_text().splitlines():
if line.startswith("btime"):
return int(line.split()[1]) * 1_000_000_000
return 0
_BOOT_NS = _boot_time_ns()
def _ktime_to_datetime(ktime_ns: int) -> datetime:
"""
Convert kernel monotonic timestamp to wall clock
"""
epoch_ns = _BOOT_NS + ktime_ns
return datetime.fromtimestamp(epoch_ns / 1_000_000_000,
tz=timezone.utc)
def parse_raw_event(
ctx: Any,
data: Any,
size: int,
) -> TracerEvent:
"""
Convert raw ring buffer bytes to a TracerEvent
"""
raw = ctypes.cast(data, ctypes.POINTER(RawEvent)).contents
etype = raw.event_type
type_name = EVENT_TYPE_NAMES.get(etype, f"unknown_{etype}")
category = EVENT_TYPE_CATEGORIES.get(etype, "unknown")
return TracerEvent(
timestamp=_ktime_to_datetime(raw.timestamp_ns),
event_type=type_name,
category=category,
pid=raw.pid,
ppid=raw.ppid,
uid=raw.uid,
gid=raw.gid,
username=_resolve_username(raw.uid),
comm=_decode_comm(raw.comm),
filename=_decode_filename(raw.filename),
addr_v4=_ipv4_to_str(raw.addr_v4),
port=raw.port,
protocol=raw.protocol,
target_uid=raw.target_uid,
target_gid=raw.target_gid,
ptrace_request=raw.ptrace_request,
target_pid=raw.target_pid,
flags=raw.flags,
)
def _resolve_parent_comm(ppid: int) -> str:
"""
Read parent process name from /proc
"""
comm_path = Path(f"/proc/{ppid}/comm")
try:
return comm_path.read_text().strip()
except (FileNotFoundError, PermissionError):
return ""
def enrich_event(event: TracerEvent) -> TracerEvent:
"""
Add additional context from /proc filesystem
"""
parent_comm = _resolve_parent_comm(event.ppid)
if parent_comm:
event.extra["parent_comm"] = parent_comm
return event
def should_include(
event: TracerEvent,
min_severity: Severity,
pid_filter: int | None,
comm_filter: str | None,
tracer_type: TracerType,
detections_only: bool,
) -> bool:
"""
Determine if an event passes all active filters
"""
if detections_only and event.detection is None:
return False
sev_val = SEVERITY_ORDER.get(event.severity, 0)
min_val = SEVERITY_ORDER.get(min_severity, 0)
if sev_val < min_val:
return False
if pid_filter is not None and event.pid != pid_filter:
return False
if (comm_filter is not None and event.comm != comm_filter):
return False
if tracer_type != "all" and event.category != tracer_type:
return False
return True

View File

@ -0,0 +1,264 @@
"""
©AngelaMos | 2026
renderer.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import IO, Any, TextIO
from rich.console import Console
from rich.table import Table
from rich.text import Text
from .config import (
SEVERITY_COLORS,
OutputFormat,
)
from .processor import TracerEvent
def _event_to_dict(event: TracerEvent) -> dict[str, Any]:
"""
Serialize a TracerEvent to a JSON-compatible dict
"""
d: dict[str, Any] = {
"timestamp": event.timestamp.isoformat(),
"event_type": event.event_type,
"category": event.category,
"pid": event.pid,
"ppid": event.ppid,
"uid": event.uid,
"username": event.username,
"comm": event.comm,
"severity": event.severity,
}
if event.filename:
d["filename"] = event.filename
if event.addr_v4:
d["dest_ip"] = event.addr_v4
d["dest_port"] = event.port
if event.event_type in ("setuid", "setgid"):
d["target_uid"] = event.target_uid
d["target_gid"] = event.target_gid
if event.event_type == "ptrace":
d["ptrace_request"] = event.ptrace_request
d["target_pid"] = event.target_pid
if event.detection:
d["detection"] = event.detection
d["detection_id"] = event.detection_id
d["mitre_id"] = event.mitre_id
parent_comm = event.extra.get("parent_comm")
if parent_comm:
d["parent_comm"] = parent_comm
return d
class JsonRenderer:
"""
Outputs one JSON object per line
"""
def __init__(
self,
stream: TextIO = sys.stdout,
) -> None:
"""
Initialize with output stream
"""
self._stream = stream
def render(self, event: TracerEvent) -> None:
"""
Write a single event as JSON
"""
d = _event_to_dict(event)
self._stream.write(json.dumps(d) + "\n")
self._stream.flush()
class LiveRenderer:
"""
Color-coded streaming output using Rich
"""
def __init__(
self,
console: Console | None = None,
) -> None:
"""
Initialize with Rich console
"""
self._console = console or Console()
def render(self, event: TracerEvent) -> None:
"""
Print a color-coded event line
"""
ts = event.timestamp.strftime("%H:%M:%S")
color = SEVERITY_COLORS.get(event.severity, "white")
sev = Text(f"{event.severity:8s}", style=color)
detail = self._format_detail(event)
line = Text()
line.append(f"[{ts}] ")
line.append_text(sev)
line.append(f" {event.event_type:14s} ")
line.append(f"pid={event.pid} "
f"comm={event.comm} ")
if detail:
line.append(detail)
if event.detection:
det_text = Text(
f" [{event.detection}]",
style="bold magenta",
)
line.append_text(det_text)
self._console.print(line)
def _format_detail(
self,
event: TracerEvent,
) -> str:
"""
Build detail string based on event type
"""
if event.filename:
return event.filename
if event.addr_v4:
return f"{event.addr_v4}:{event.port}"
if event.target_uid:
return f"uid->{event.target_uid}"
if event.ptrace_request:
return (f"req={event.ptrace_request} "
f"target={event.target_pid}")
return ""
class TableRenderer:
"""
Periodic table summaries using Rich
"""
def __init__(
self,
console: Console | None = None,
) -> None:
"""
Initialize with Rich console and event buffer
"""
self._console = console or Console()
self._buffer: list[TracerEvent] = []
self._flush_count = 20
def render(self, event: TracerEvent) -> None:
"""
Buffer events and flush as table periodically
"""
self._buffer.append(event)
if len(self._buffer) >= self._flush_count:
self._flush()
def _flush(self) -> None:
"""
Render buffered events as a Rich table
"""
if not self._buffer:
return
table = Table(
title="eBPF Security Events",
show_lines=False,
)
table.add_column("Time", width=8)
table.add_column("Severity", width=8)
table.add_column("Type", width=12)
table.add_column("PID", width=7)
table.add_column("Comm", width=15)
table.add_column("Detail", min_width=20)
table.add_column("Detection", width=18)
for ev in self._buffer:
color = SEVERITY_COLORS.get(ev.severity, "white")
ts = ev.timestamp.strftime("%H:%M:%S")
detail = ""
if ev.filename:
detail = ev.filename
elif ev.addr_v4:
detail = f"{ev.addr_v4}:{ev.port}"
table.add_row(
ts,
Text(ev.severity, style=color),
ev.event_type,
str(ev.pid),
ev.comm,
detail,
ev.detection or "",
)
self._console.print(table)
self._buffer.clear()
def finalize(self) -> None:
"""
Flush remaining events on shutdown
"""
self._flush()
class FileRenderer:
"""
Append JSON events to a file
"""
def __init__(self, path: Path) -> None:
"""
Initialize with output file path
"""
self._path = path
self._fh: IO[str] | None = None
def render(self, event: TracerEvent) -> None:
"""
Append a single event as JSON to the file
"""
if self._fh is None:
self._fh = open(self._path, "a")
d = _event_to_dict(event)
self._fh.write(json.dumps(d) + "\n")
self._fh.flush()
def close(self) -> None:
"""
Close the output file handle
"""
if self._fh is not None:
self._fh.close()
self._fh = None
def create_renderer(
fmt: OutputFormat, ) -> JsonRenderer | LiveRenderer | TableRenderer:
"""
Factory for the appropriate renderer
"""
if fmt == "json":
return JsonRenderer()
if fmt == "table":
return TableRenderer()
return LiveRenderer()

View File

@ -0,0 +1,92 @@
[
{
"timestamp_ns": 1000000000,
"pid": 1234,
"ppid": 1000,
"uid": 1000,
"gid": 1000,
"event_type": 1,
"ret_val": 0,
"comm": "bash",
"filename": "/usr/bin/ls",
"addr_v4": 0,
"port": 0,
"protocol": 0,
"target_uid": 0,
"target_gid": 0,
"ptrace_request": 0,
"target_pid": 0
},
{
"timestamp_ns": 2000000000,
"pid": 1234,
"ppid": 1000,
"uid": 1000,
"gid": 1000,
"event_type": 3,
"ret_val": 0,
"comm": "python3",
"filename": "/etc/shadow",
"addr_v4": 0,
"port": 0,
"protocol": 0,
"target_uid": 0,
"target_gid": 0,
"ptrace_request": 0,
"target_pid": 0
},
{
"timestamp_ns": 3000000000,
"pid": 5678,
"ppid": 5600,
"uid": 1000,
"gid": 1000,
"event_type": 10,
"ret_val": 0,
"comm": "exploit",
"filename": "",
"addr_v4": 0,
"port": 0,
"protocol": 0,
"target_uid": 0,
"target_gid": 0,
"ptrace_request": 0,
"target_pid": 0
},
{
"timestamp_ns": 4000000000,
"pid": 9999,
"ppid": 9900,
"uid": 0,
"gid": 0,
"event_type": 6,
"ret_val": 0,
"comm": "nc",
"filename": "",
"addr_v4": 167772161,
"port": 4444,
"protocol": 2,
"target_uid": 0,
"target_gid": 0,
"ptrace_request": 0,
"target_pid": 0
},
{
"timestamp_ns": 5000000000,
"pid": 7777,
"ppid": 7700,
"uid": 0,
"gid": 0,
"event_type": 12,
"ret_val": 0,
"comm": "injector",
"filename": "",
"addr_v4": 0,
"port": 0,
"protocol": 0,
"target_uid": 0,
"target_gid": 0,
"ptrace_request": 16,
"target_pid": 1234
}
]

View File

@ -0,0 +1,4 @@
"""
©AngelaMos | 2026
__init__.py
"""

View File

@ -0,0 +1,61 @@
"""
©AngelaMos | 2026
conftest.py
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from src.processor import TracerEvent
@pytest.fixture()
def make_event():
"""
Factory for creating TracerEvent instances in tests
"""
def _make(
event_type: str = "execve",
category: str = "process",
pid: int = 1000,
ppid: int = 999,
uid: int = 1000,
gid: int = 1000,
comm: str = "test",
filename: str = "",
addr_v4: str = "",
port: int = 0,
protocol: int = 0,
target_uid: int = 0,
target_gid: int = 0,
ptrace_request: int = 0,
target_pid: int = 0,
flags: int = 0,
severity: str = "LOW",
) -> TracerEvent:
return TracerEvent(
timestamp=datetime.now(tz=timezone.utc),
event_type=event_type,
category=category,
pid=pid,
ppid=ppid,
uid=uid,
gid=gid,
username="testuser",
comm=comm,
filename=filename,
addr_v4=addr_v4,
port=port,
protocol=protocol,
target_uid=target_uid,
target_gid=target_gid,
ptrace_request=ptrace_request,
target_pid=target_pid,
flags=flags,
severity=severity,
)
return _make

View File

@ -0,0 +1,474 @@
"""
©AngelaMos | 2026
test_detector.py
"""
from __future__ import annotations
from src.detector import DetectionEngine
class TestPrivilegeEscalation:
"""
Tests for D001 privilege escalation detection
"""
def test_setuid_zero_by_nonroot(self, make_event):
"""
Detects setuid(0) from non-root UID
"""
engine = DetectionEngine()
event = make_event(
event_type="setuid",
category="privilege",
uid=1000,
target_uid=0,
)
result = engine.evaluate(event)
assert result.detection == "Privilege Escalation"
assert result.severity == "CRITICAL"
assert result.detection_id == "D001"
def test_setuid_zero_by_root_ignored(self, make_event):
"""
Allows setuid(0) when already root
"""
engine = DetectionEngine()
event = make_event(
event_type="setuid",
category="privilege",
uid=0,
target_uid=0,
)
result = engine.evaluate(event)
assert result.detection is None
def test_setuid_nonzero_ignored(self, make_event):
"""
Allows setuid to non-root UIDs
"""
engine = DetectionEngine()
event = make_event(
event_type="setuid",
category="privilege",
uid=1000,
target_uid=1001,
)
result = engine.evaluate(event)
assert result.detection is None
class TestSensitiveFileRead:
"""
Tests for D002 sensitive file read detection
"""
def test_shadow_read_nonroot(self, make_event):
"""
Detects /etc/shadow access by non-root
"""
engine = DetectionEngine()
event = make_event(
event_type="openat",
category="file",
uid=1000,
filename="/etc/shadow",
)
result = engine.evaluate(event)
assert result.detection == "Sensitive File Read"
assert result.severity == "MEDIUM"
def test_shadow_read_root_ignored(self, make_event):
"""
Allows /etc/shadow access by root
"""
engine = DetectionEngine()
event = make_event(
event_type="openat",
category="file",
uid=0,
filename="/etc/shadow",
)
result = engine.evaluate(event)
assert result.detection is None
def test_gshadow_read_nonroot(self, make_event):
"""
Detects /etc/gshadow access by non-root
"""
engine = DetectionEngine()
event = make_event(
event_type="openat",
category="file",
uid=1000,
filename="/etc/gshadow",
)
result = engine.evaluate(event)
assert result.detection == "Sensitive File Read"
def test_shadow_write_not_read_detection(self, make_event):
"""
Write to sensitive file does not trigger read detection
"""
engine = DetectionEngine()
event = make_event(
event_type="openat",
category="file",
uid=1000,
filename="/etc/shadow",
flags=1,
)
result = engine.evaluate(event)
assert result.detection != "Sensitive File Read"
class TestSSHKeyAccess:
"""
Tests for D003 SSH key access detection
"""
def test_ssh_private_key(self, make_event):
"""
Detects SSH private key file access
"""
engine = DetectionEngine()
event = make_event(
event_type="openat",
category="file",
filename="/home/user/.ssh/id_rsa",
)
result = engine.evaluate(event)
assert result.detection == "SSH Key Access"
assert result.mitre_id == "T1552.004"
def test_authorized_keys(self, make_event):
"""
Detects authorized_keys access
"""
engine = DetectionEngine()
event = make_event(
event_type="openat",
category="file",
filename="/root/.ssh/authorized_keys",
)
result = engine.evaluate(event)
assert result.detection == "SSH Key Access"
def test_sshd_authorized_keys_ignored(self, make_event):
"""
Allows sshd to read authorized_keys
"""
engine = DetectionEngine()
event = make_event(
event_type="openat",
category="file",
comm="sshd",
filename="/root/.ssh/authorized_keys",
)
result = engine.evaluate(event)
assert result.detection is None
def test_ssh_agent_key_ignored(self, make_event):
"""
Allows ssh-agent to access private keys
"""
engine = DetectionEngine()
event = make_event(
event_type="openat",
category="file",
comm="ssh-agent",
filename="/home/user/.ssh/id_ed25519",
)
result = engine.evaluate(event)
assert result.detection is None
class TestProcessInjection:
"""
Tests for D004 ptrace-based injection detection
"""
def test_ptrace_attach(self, make_event):
"""
Detects PTRACE_ATTACH calls
"""
engine = DetectionEngine()
event = make_event(
event_type="ptrace",
category="system",
ptrace_request=16,
target_pid=1234,
)
result = engine.evaluate(event)
assert result.detection == "Process Injection"
assert result.mitre_id == "T1055.008"
def test_ptrace_setregs(self, make_event):
"""
Detects PTRACE_SETREGS calls
"""
engine = DetectionEngine()
event = make_event(
event_type="ptrace",
category="system",
ptrace_request=13,
target_pid=1234,
)
result = engine.evaluate(event)
assert result.detection == "Process Injection"
def test_ptrace_traceme_ignored(self, make_event):
"""
Ignores PTRACE_TRACEME (normal debugging)
"""
engine = DetectionEngine()
event = make_event(
event_type="ptrace",
category="system",
ptrace_request=0,
target_pid=0,
)
result = engine.evaluate(event)
assert result.detection is None
class TestKernelModule:
"""
Tests for D005 kernel module load detection
"""
def test_init_module(self, make_event):
"""
Detects init_module calls
"""
engine = DetectionEngine()
event = make_event(
event_type="init_module",
category="system",
)
result = engine.evaluate(event)
assert result.detection == "Kernel Module Load"
assert result.severity == "HIGH"
class TestPersistence:
"""
Tests for D007/D008 persistence detection
"""
def test_cron_write(self, make_event):
"""
Detects writes to cron directories
"""
engine = DetectionEngine()
event = make_event(
event_type="openat",
category="file",
filename="/etc/cron.d/backdoor",
flags=1,
)
result = engine.evaluate(event)
assert result.detection == "Persistence via Cron"
def test_systemd_write(self, make_event):
"""
Detects writes to systemd unit directories
"""
engine = DetectionEngine()
event = make_event(
event_type="openat",
category="file",
filename="/etc/systemd/system/evil.service",
flags=1,
)
result = engine.evaluate(event)
assert result.detection == "Persistence via Systemd"
def test_cron_read_ignored(self, make_event):
"""
Allows reads from cron directories
"""
engine = DetectionEngine()
event = make_event(
event_type="openat",
category="file",
filename="/etc/cron.d/something",
flags=0,
)
result = engine.evaluate(event)
assert result.detection is None
class TestLogTampering:
"""
Tests for D009 log tampering detection
"""
def test_log_truncation(self, make_event):
"""
Detects log file truncation
"""
engine = DetectionEngine()
event = make_event(
event_type="openat",
category="file",
filename="/var/log/auth.log",
flags=512,
)
result = engine.evaluate(event)
assert result.detection == "Log Tampering"
def test_log_deletion(self, make_event):
"""
Detects log file deletion
"""
engine = DetectionEngine()
event = make_event(
event_type="unlinkat",
category="file",
filename="/var/log/syslog",
)
result = engine.evaluate(event)
assert result.detection == "Log Tampering"
class TestReverseShell:
"""
Tests for D006 reverse shell correlation detection
"""
def test_connect_then_shell(self, make_event):
"""
Detects shell spawn after network connect
"""
engine = DetectionEngine()
connect_event = make_event(
event_type="connect",
category="network",
pid=2000,
addr_v4="10.0.0.1",
port=4444,
)
engine.evaluate(connect_event)
shell_event = make_event(
event_type="execve",
category="process",
pid=2000,
comm="bash",
filename="/bin/bash",
)
result = engine.evaluate(shell_event)
assert result.detection == "Reverse Shell"
assert result.severity == "CRITICAL"
def test_shell_without_connect(self, make_event):
"""
Normal shell execution is not flagged
"""
engine = DetectionEngine()
event = make_event(
event_type="execve",
category="process",
pid=3000,
comm="bash",
filename="/bin/bash",
)
result = engine.evaluate(event)
assert result.detection is None
def test_connect_then_nonshell(self, make_event):
"""
Non-shell execution after connect is not flagged
"""
engine = DetectionEngine()
connect_event = make_event(
event_type="connect",
category="network",
pid=4000,
addr_v4="10.0.0.1",
port=80,
)
engine.evaluate(connect_event)
exec_event = make_event(
event_type="execve",
category="process",
pid=4000,
comm="curl",
filename="/usr/bin/curl",
)
result = engine.evaluate(exec_event)
assert result.detection is None
def test_shell_then_connect(self, make_event):
"""
Detects network connect after shell execution
"""
engine = DetectionEngine()
shell_event = make_event(
event_type="execve",
category="process",
pid=5000,
comm="bash",
filename="/bin/bash",
)
engine.evaluate(shell_event)
connect_event = make_event(
event_type="connect",
category="network",
pid=5000,
addr_v4="10.0.0.1",
port=4444,
)
result = engine.evaluate(connect_event)
assert result.detection == "Reverse Shell"
assert result.severity == "CRITICAL"
def test_nonshell_then_connect(self, make_event):
"""
Connect after non-shell exec is not flagged
"""
engine = DetectionEngine()
exec_event = make_event(
event_type="execve",
category="process",
pid=6000,
comm="curl",
filename="/usr/bin/curl",
)
engine.evaluate(exec_event)
connect_event = make_event(
event_type="connect",
category="network",
pid=6000,
addr_v4="10.0.0.1",
port=80,
)
result = engine.evaluate(connect_event)
assert result.detection is None
class TestMount:
"""
Tests for D010 suspicious mount detection
"""
def test_mount_detected(self, make_event):
"""
Detects mount syscalls
"""
engine = DetectionEngine()
event = make_event(
event_type="mount",
category="system",
filename="/dev/sda1",
)
result = engine.evaluate(event)
assert result.detection == "Suspicious Mount"
assert result.severity == "HIGH"

View File

@ -0,0 +1,163 @@
"""
©AngelaMos | 2026
test_processor.py
"""
from __future__ import annotations
from src.processor import (
_decode_comm,
_decode_filename,
_ipv4_to_str,
should_include,
)
class TestDecodeComm:
"""
Tests for comm field decoding
"""
def test_normal_string(self):
"""
Decodes a normal null-terminated comm
"""
raw = b"bash\x00\x00\x00\x00"
assert _decode_comm(raw) == "bash"
def test_full_length(self):
"""
Handles comm at max length without null
"""
raw = b"long_process_nm"
assert _decode_comm(raw) == "long_process_nm"
def test_empty(self):
"""
Handles empty comm
"""
raw = b"\x00" * 16
assert _decode_comm(raw) == ""
class TestDecodeFilename:
"""
Tests for filename field decoding
"""
def test_normal_path(self):
"""
Decodes a normal filepath
"""
raw = b"/usr/bin/ls\x00"
assert _decode_filename(raw) == "/usr/bin/ls"
def test_empty(self):
"""
Handles empty filename
"""
raw = b"\x00" * 256
assert _decode_filename(raw) == ""
class TestIpv4ToStr:
"""
Tests for IPv4 address conversion
"""
def test_localhost(self):
"""
Converts 127.0.0.1 in network byte order
"""
assert _ipv4_to_str(0x0100007F) == "127.0.0.1"
def test_ten_network(self):
"""
Converts 10.0.0.1 in network byte order
"""
assert _ipv4_to_str(0x0100000A) == "10.0.0.1"
def test_zero(self):
"""
Returns empty string for zero address
"""
assert _ipv4_to_str(0) == ""
class TestShouldInclude:
"""
Tests for event filtering logic
"""
def test_passes_all_defaults(self, make_event):
"""
Event passes with default filters
"""
event = make_event()
assert should_include(event, "LOW", None, None, "all", False)
def test_severity_filter(self, make_event):
"""
Filters events below minimum severity
"""
event = make_event(severity="LOW")
assert not should_include(event, "MEDIUM", None, None, "all",
False)
def test_severity_passes(self, make_event):
"""
Passes events at or above minimum severity
"""
event = make_event(severity="HIGH")
assert should_include(event, "MEDIUM", None, None, "all", False)
def test_pid_filter_match(self, make_event):
"""
Passes when PID matches filter
"""
event = make_event(pid=1234)
assert should_include(event, "LOW", 1234, None, "all", False)
def test_pid_filter_mismatch(self, make_event):
"""
Filters when PID does not match
"""
event = make_event(pid=1234)
assert not should_include(event, "LOW", 5678, None, "all", False)
def test_comm_filter_match(self, make_event):
"""
Passes when comm matches filter
"""
event = make_event(comm="bash")
assert should_include(event, "LOW", None, "bash", "all", False)
def test_comm_filter_mismatch(self, make_event):
"""
Filters when comm does not match
"""
event = make_event(comm="bash")
assert not should_include(event, "LOW", None, "python", "all",
False)
def test_type_filter(self, make_event):
"""
Filters events outside requested category
"""
event = make_event(category="process")
assert not should_include(event, "LOW", None, None, "network",
False)
def test_detections_only_with_detection(self, make_event):
"""
Passes events with detections in detections mode
"""
event = make_event()
event.detection = "Test Detection"
assert should_include(event, "LOW", None, None, "all", True)
def test_detections_only_without_detection(self, make_event):
"""
Filters events without detections in detections mode
"""
event = make_event()
assert not should_include(event, "LOW", None, None, "all", True)

View File

@ -0,0 +1,140 @@
"""
©AngelaMos | 2026
test_renderer.py
"""
from __future__ import annotations
import io
import json
from src.renderer import JsonRenderer, _event_to_dict
class TestEventToDict:
"""
Tests for event serialization
"""
def test_basic_fields(self, make_event):
"""
Serializes core event fields
"""
event = make_event(
event_type="execve",
pid=1234,
comm="bash",
)
d = _event_to_dict(event)
assert d["event_type"] == "execve"
assert d["pid"] == 1234
assert d["comm"] == "bash"
assert "timestamp" in d
def test_filename_included(self, make_event):
"""
Includes filename when present
"""
event = make_event(filename="/usr/bin/ls")
d = _event_to_dict(event)
assert d["filename"] == "/usr/bin/ls"
def test_filename_excluded_when_empty(self, make_event):
"""
Omits filename when empty
"""
event = make_event(filename="")
d = _event_to_dict(event)
assert "filename" not in d
def test_network_fields(self, make_event):
"""
Includes network fields for connect events
"""
event = make_event(
event_type="connect",
addr_v4="10.0.0.1",
port=4444,
)
d = _event_to_dict(event)
assert d["dest_ip"] == "10.0.0.1"
assert d["dest_port"] == 4444
def test_detection_fields(self, make_event):
"""
Includes detection metadata when present
"""
event = make_event()
event.detection = "Test Rule"
event.detection_id = "D999"
event.mitre_id = "T1234"
d = _event_to_dict(event)
assert d["detection"] == "Test Rule"
assert d["detection_id"] == "D999"
assert d["mitre_id"] == "T1234"
def test_no_detection_fields_when_none(self, make_event):
"""
Omits detection fields when no detection
"""
event = make_event()
d = _event_to_dict(event)
assert "detection" not in d
def test_setuid_zero_serialized(self, make_event):
"""
Includes target_uid even when value is zero
"""
event = make_event(
event_type="setuid",
target_uid=0,
)
d = _event_to_dict(event)
assert "target_uid" in d
assert d["target_uid"] == 0
def test_ptrace_fields_serialized(self, make_event):
"""
Includes ptrace fields for ptrace events
"""
event = make_event(
event_type="ptrace",
ptrace_request=16,
target_pid=1234,
)
d = _event_to_dict(event)
assert d["ptrace_request"] == 16
assert d["target_pid"] == 1234
class TestJsonRenderer:
"""
Tests for JSON output rendering
"""
def test_outputs_valid_json(self, make_event):
"""
Produces valid JSON output
"""
buf = io.StringIO()
renderer = JsonRenderer(stream=buf)
event = make_event(event_type="execve", pid=42, comm="ls")
renderer.render(event)
output = buf.getvalue().strip()
parsed = json.loads(output)
assert parsed["pid"] == 42
assert parsed["comm"] == "ls"
def test_one_line_per_event(self, make_event):
"""
Each event is a single line
"""
buf = io.StringIO()
renderer = JsonRenderer(stream=buf)
renderer.render(make_event(pid=1))
renderer.render(make_event(pid=2))
lines = buf.getvalue().strip().split("\n")
assert len(lines) == 2
assert json.loads(lines[0])["pid"] == 1
assert json.loads(lines[1])["pid"] == 2

View File

@ -0,0 +1,459 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "click"
version = "8.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "ebpf-security-tracer"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "rich" },
{ name = "typer" },
]
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "pytest" },
{ name = "ruff" },
{ name = "yapf" },
]
[package.metadata]
requires-dist = [
{ name = "rich", specifier = ">=14.0.0" },
{ name = "typer", specifier = ">=0.21.1" },
]
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = ">=1.19.0" },
{ name = "pytest", specifier = ">=9.0.0" },
{ name = "ruff", specifier = ">=0.14.0" },
{ name = "yapf", specifier = ">=0.43.0" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "librt"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" },
{ url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" },
{ url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" },
{ url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" },
{ url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" },
{ url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" },
{ url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" },
{ url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" },
{ url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" },
{ url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" },
{ url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" },
{ url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" },
{ url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" },
{ url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" },
{ url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" },
{ url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" },
{ url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" },
{ url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" },
{ url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" },
{ url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" },
{ url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" },
{ url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" },
{ url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" },
{ url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" },
{ url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" },
{ url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" },
{ url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" },
{ url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" },
{ url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
{ url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
{ url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
{ url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
{ url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
{ url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
{ url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
{ url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
{ url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
{ url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
{ url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
{ url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
{ url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
{ url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
{ url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
{ url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
{ url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" },
{ url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" },
{ url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" },
{ url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
{ url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
{ url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
{ url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
{ url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
{ url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" },
{ url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mypy"
version = "1.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/a2/a965c8c3fcd4fa8b84ba0d46606181b0d0a1d50f274c67877f3e9ed4882c/mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8", size = 14430138, upload-time = "2026-03-31T16:52:37.843Z" },
{ url = "https://files.pythonhosted.org/packages/53/6e/043477501deeb8eabbab7f1a2f6cac62cfb631806dc1d6862a04a7f5011b/mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a", size = 13311282, upload-time = "2026-03-31T16:55:11.021Z" },
{ url = "https://files.pythonhosted.org/packages/65/aa/bd89b247b83128197a214f29f0632ff3c14f54d4cd70d144d157bd7d7d6e/mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865", size = 13750889, upload-time = "2026-03-31T16:52:02.909Z" },
{ url = "https://files.pythonhosted.org/packages/fa/9d/2860be7355c45247ccc0be1501c91176318964c2a137bd4743f58ce6200e/mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca", size = 14619788, upload-time = "2026-03-31T16:50:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/75/7f/3ef3e360c91f3de120f205c8ce405e9caf9fc52ef14b65d37073e322c114/mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018", size = 14918849, upload-time = "2026-03-31T16:51:10.478Z" },
{ url = "https://files.pythonhosted.org/packages/ae/72/af970dfe167ef788df7c5e6109d2ed0229f164432ce828bc9741a4250e64/mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13", size = 10822007, upload-time = "2026-03-31T16:50:25.268Z" },
{ url = "https://files.pythonhosted.org/packages/93/94/ba9065c2ebe5421619aff684b793d953e438a8bfe31a320dd6d1e0706e81/mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281", size = 9756158, upload-time = "2026-03-31T16:48:36.213Z" },
{ url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" },
{ url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" },
{ url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" },
{ url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" },
{ url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" },
{ url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" },
{ url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" },
{ url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" },
{ url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" },
{ url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" },
{ url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" },
{ url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" },
{ url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" },
{ url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" },
{ url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" },
{ url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" },
{ url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" },
{ url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" },
{ url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" },
{ url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" },
{ url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" },
{ url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" },
{ url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" },
{ url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" },
{ url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" },
{ url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" },
{ url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "ruff"
version = "0.15.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" },
{ url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" },
{ url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" },
{ url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" },
{ url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" },
{ url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" },
{ url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" },
{ url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" },
{ url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" },
{ url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" },
{ url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" },
{ url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" },
{ url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typer"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "yapf"
version = "0.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" },
]

View File

@ -1,591 +0,0 @@
# Credential Enumeration Audit
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans
> to implement this plan task-by-task.
**Goal:** Address all gaps identified in the audit.
**Architecture:** All changes are modifications to existing files unless noted.
**Tech Stack:** Nim 2.2+, Docker, Bash (Justfile)
---
## Impression
Solid architecture for a Nim CLI tool — clean type hierarchy, consistent
`{.push raises: [].}` discipline, well-structured collector pattern. The
bones are genuinely good. But two of the command-detection patterns silently
match nothing, the terminal box renderer computes stats it never prints,
and the only test mechanism (Docker) can't actually build because the
Justfile passes the wrong build context. The tool scans 7 credential
categories competently but misses several high-value targets (.netrc,
npm/pip tokens, Terraform, Vault) that a real post-access operator would
check first.
## Project Assessment
**Type:** Rule-based credential detection CLI tool (post-access)
**Primary Axis:** Completeness — weighted 65/35 over code quality
**Why:** A scanner's value is directly proportional to what it catches.
Missing a credential category is a harder failure than a rendering bug.
## Findings
### Finding 1: Docker test build context is wrong — entire test pipeline broken
**Severity:** CRITICAL
**Axis:** Code Quality
**Files:** Justfile:88-89, tests/docker/Dockerfile:1-12
**Issue:** The Justfile recipe `docker-build` runs
`docker build -t credenum-test tests/docker`, setting the build context to
`tests/docker/`. But the Dockerfile's first stage copies `src/`, `config.nims`,
and `credential-enumeration.nimble` from the build context root — none of which
exist under `tests/docker/`. The build fails immediately with
"COPY failed: file not found in build context."
**Proof:** The Dockerfile contains:
```dockerfile
COPY src/ src/
COPY config.nims .
COPY credential-enumeration.nimble .
```
With context `tests/docker/`, Docker looks for `tests/docker/src/`,
`tests/docker/config.nims`, `tests/docker/credential-enumeration.nimble`.
None exist — `find tests/docker/ -name "config.nims"` returns nothing.
The only test mechanism for this project has never run successfully with
this Justfile recipe.
**Proof Check:** Confidence: HIGH — Docker build context semantics are deterministic;
this is not a maybe.
**Fix:**
`Justfile:88-89` — change the docker-build recipe to use the project root as context:
```just
[group('test')]
docker-build:
docker build -t credenum-test -f tests/docker/Dockerfile .
```
And update `docker-test` accordingly (it depends on docker-build, so no change needed
there since it just `docker run`s the image).
**Test:**
```bash
just docker-build
```
---
### Finding 2: matchesCommandPattern has case mismatch — 2/7 patterns are dead code
**Severity:** CRITICAL
**Axis:** Code Quality
**Files:** src/collectors/history.nim:38-54, src/config.nim:120-128
**Issue:** `matchesCommandPattern` lowercases the input line (`line.toLowerAscii()`)
then searches for pattern fragments that contain uppercase characters. Two patterns
are affected:
- `"curl.*-H.*[Aa]uthoriz"` splits into `["curl", "-H", "[Aa]uthoriz"]`
`-H` (uppercase) will never be found in a lowercased string, and
`[Aa]uthoriz` is treated as a literal (not a character class)
- `"wget.*--header.*[Aa]uthoriz"` splits into `["wget", "--header", "[Aa]uthoriz"]`
`[Aa]uthoriz` is literal and will never appear in real history
This means `curl -H "Authorization: Bearer ..."` commands in shell history
are silently missed — one of the most common credential-leaking patterns.
**Proof:** Trace through `matchesCommandPattern` with input
`curl -H "Authorization: Bearer token" https://api.example.com`:
1. `lower` = `curl -h "authorization: bearer token" https://api.example.com`
2. Pattern `"curl.*-H.*[Aa]uthoriz"` → parts = `["curl", "-H", "[Aa]uthoriz"]`
3. `lower.find("curl")` → found at 0
4. `lower.find("-H")` → NOT FOUND (lowercase string has `-h`, not `-H`)
5. `allFound = false` → returns false
The pattern never matches. The planted test data in `.bash_history` line 4
has `curl -H "Authorization: ..."` which should trigger this pattern but
the validate.sh check labeled "Sensitive command" passes only because
OTHER patterns (like `sshpass`, `mysql.*-p`) produce matches.
**Proof Check:** Confidence: HIGH — Nim's `find` is case-sensitive by default;
this is deterministic.
**Fix:**
`src/config.nim:120-128` — lowercase all pattern fragments:
```nim
HistoryCommandPatterns* = [
"curl.*-h.*authoriz",
"curl.*-u ",
"wget.*--header.*authoriz",
"wget.*--password",
"mysql.*-p",
"psql.*password",
"sshpass"
]
```
**Test:**
Add a Docker test assertion that specifically validates curl -H Authorization
detection. After fix, run `just docker-test`.
---
### Finding 3: Module header stats computed but never rendered
**Severity:** MAJOR
**Axis:** Code Quality
**Files:** src/output/terminal.nim:40-57
**Issue:** `renderModuleHeader` computes a `stats` string containing
the finding count and duration, but the padding calculation
`padLen - stats.len + stats.len` simplifies to just `padLen` — then
writes padding spaces without ever writing `stats` to stdout.
The finding count and per-module duration are silently dropped from output.
**Proof:** The arithmetic:
```nim
let stats = $findingCount & " findings" & ColorDim & " (" & $durationMs & "ms)" & ColorReset
let padLen = 76 - name.len - desc.len - 5
if padLen > 0:
stdout.write " ".repeat(padLen - stats.len + stats.len) # = " ".repeat(padLen)
stdout.writeLine " " & BoxVertical
```
`stats` is never passed to `stdout.write`. The line is equivalent to
`stdout.write " ".repeat(padLen)` followed by the box border — no stats
anywhere.
**Proof Check:** Confidence: HIGH — the variable is computed and never
appears in any write call in the function.
**Fix:**
`src/output/terminal.nim:51-55` — compute visual width (excluding ANSI codes),
pad to fill the box, then write stats:
```nim
proc visualLen(s: string): int =
var i = 0
while i < s.len:
if s[i] == '\e':
while i < s.len and s[i] != 'm':
inc i
inc i
else:
inc result
inc i
proc renderModuleHeader(name: string, desc: string, findingCount: int, durationMs: int64) =
try:
stdout.writeLine boxLine(78)
stdout.write BoxVertical & " "
stdout.write ColorBold & ColorCyan
stdout.write name.toUpperAscii()
stdout.write ColorReset
stdout.write ColorDim
stdout.write " " & Arrow & " " & desc
stdout.write ColorReset
let stats = $findingCount & " findings" & ColorDim & " (" & $durationMs & "ms)" & ColorReset
let usedWidth = 2 + name.len + 3 + desc.len
let statsVisual = visualLen(stats)
let padLen = 78 - usedWidth - statsVisual - 2
if padLen > 0:
stdout.write " ".repeat(padLen)
stdout.write stats
stdout.writeLine " " & BoxVertical
stdout.writeLine boxMid(78)
except CatchableError:
discard
```
**Test:**
```bash
just run --target /tmp | head -20
```
Verify module headers show "N findings (Xms)" right-aligned.
---
### Finding 4: Terminal box right-border alignment broken for variable content
**Severity:** MAJOR
**Axis:** Code Quality
**Files:** src/output/terminal.nim:60-84, 98-126
**Issue:** `renderFinding` writes descriptions and paths of arbitrary length
then appends `" " & BoxVertical` with no padding to reach column 78. Long
descriptions push past the box. Short ones leave the right border floating
at different positions. Same issue in `renderSummary` — hardcoded
`" ".repeat(69)` and `" ".repeat(20)` assume fixed content widths that
vary with finding counts, module counts, and durations.
**Proof:** A finding with path `/home/user/.config/google-chrome/Default/Login Data`
(49 chars) plus permissions `[0644]` plus modified timestamp is ~90+ chars of
content in a 78-char box. The right `BoxVertical` gets pushed to column ~95.
A finding with path `/home/user/.pgpass` (18 chars) leaves the right border
at ~column 50.
**Proof Check:** Confidence: HIGH — the code has zero width calculation before
writing the trailing BoxVertical.
**Fix:**
Create a `padWrite` helper that calculates visual width of content written so
far and pads to fill the 78-char box before writing the closing border.
Apply it to `renderFinding`, `renderSummary`, and `renderModuleErrors`.
Truncate content that would exceed box width.
In `src/output/terminal.nim`, add the `visualLen` proc from Finding 3
(shared), then refactor each line that writes content + BoxVertical:
```nim
proc padToBox(content: string, boxWidth: int = 78) =
let vLen = visualLen(content)
let pad = boxWidth - vLen - 1
if pad > 0:
stdout.write " ".repeat(pad)
stdout.writeLine BoxVertical
```
Then each finding line becomes:
```nim
var line = BoxVertical & " " & sevBadge(f.severity) & " " & f.description
stdout.write line
padToBox(line)
```
Apply this pattern consistently to all content rows in the terminal renderer.
**Test:**
```bash
just docker-test
```
Visual inspection of terminal output — all right borders should align at column 78.
---
### Finding 5: scanGitCredentials reports svHigh for empty credential files
**Severity:** MAJOR
**Axis:** Code Quality
**Files:** src/collectors/git.nim:11-39
**Issue:** If `.git-credentials` exists but is empty or contains no valid URLs,
`credCount` stays at 0 but the function still creates a finding with
"Plaintext Git credential store with 0 entries" at severity svHigh
(or svCritical if world-readable). An empty file is not a high-severity
credential exposure.
**Proof:** Trace through `scanGitCredentials` with an empty `.git-credentials`:
1. `safeFileExists` returns true
2. `readFileLines` returns `@[]`
3. Loop runs zero iterations, `credCount = 0`
4. Code falls through to create credential and finding with `svHigh`
5. Report shows "Plaintext Git credential store with 0 entries" as HIGH
**Proof Check:** Confidence: HIGH — there is no guard checking `credCount > 0`
before creating the finding.
**Fix:**
`src/collectors/git.nim` — add early return after counting:
```nim
if credCount == 0:
return
```
Insert after the for-loop that counts credentials (after line 22), before
the credential/finding construction.
**Test:**
Create an empty `.git-credentials` file, run scanner, verify no git finding
appears.
---
### Finding 6: `just test` references non-existent test_all.nim
**Severity:** MAJOR
**Axis:** Code Quality
**Files:** Justfile:84-85
**Issue:** The Justfile `test` recipe runs `nim c -r tests/test_all.nim`,
but this file does not exist. There are no unit tests in the project.
The only testing is Docker-based integration testing (validate.sh), which
itself is broken (Finding 1).
**Proof:** `test -f tests/test_all.nim` returns non-zero. The `tests/`
directory contains only `docker/`.
**Proof Check:** Confidence: HIGH — file does not exist.
**Fix:**
Create `tests/test_all.nim` with unit tests for each collector's core logic.
At minimum, test:
- `isPrivateKey` with various key headers
- `isEncrypted` with encrypted/unencrypted markers
- `matchesSecretPattern` with positive and negative cases
- `matchesCommandPattern` (after fixing Finding 2) with all 7 patterns
- `redactValue` edge cases
- `permissionSeverity` logic
- `parseModules` from CLI parsing
These should be fast, in-process tests that don't require Docker or
real credential files.
**Test:**
```bash
just test
```
---
### Finding 7: Missing credential categories — .netrc, npm/pip tokens, Terraform, Vault, GitHub CLI
**Severity:** MAJOR
**Axis:** Completeness
**Files:** src/config.nim, src/collectors/apptoken.nim
**Issue:** The tool covers 7 categories but misses several high-value
credential stores that a post-access operator would check:
| Missing Target | Path | Why It Matters |
|---|---|---|
| `.netrc` | `~/.netrc` | Universal HTTP auth store; Heroku, Artifactory, many tools |
| `.npmrc` | `~/.npmrc` | npm registry auth tokens (`_authToken=`) |
| `.pypirc` | `~/.pypirc` | PyPI upload tokens |
| GitHub CLI | `~/.config/gh/hosts.yml` | GitHub OAuth tokens |
| Terraform | `~/.terraform.d/credentials.tfrc.json` | Terraform Cloud API tokens |
| Vault | `~/.vault-token` | HashiCorp Vault root/user tokens |
| `~/.config/helm/repositories.yaml` | Helm chart repo credentials |
| `~/.config/rclone/rclone.conf` | Cloud storage credentials (S3, GCS, etc.) |
Industry comparison: LaZagne (closest post-access tool) covers 20+
credential categories on Linux alone. `truffleHog` detects 700+ secret
patterns. This tool's 7 categories leave real coverage gaps.
**Proof:** `grep -r "netrc\|npmrc\|pypirc\|vault-token\|terraform\|gh/hosts" src/`
returns zero matches.
**Proof Check:** Confidence: HIGH — the files are either scanned or they're not.
**Fix:**
Add constants to `src/config.nim`:
```nim
const
NetrcFile* = ".netrc"
NpmrcFile* = ".npmrc"
PypircFile* = ".pypirc"
GhCliHosts* = ".config/gh/hosts.yml"
TerraformCreds* = ".terraform.d/credentials.tfrc.json"
VaultTokenFile* = ".vault-token"
HelmRepos* = ".config/helm/repositories.yaml"
RcloneConf* = ".config/rclone/rclone.conf"
```
Add scanning logic to `src/collectors/apptoken.nim` — each is a simple
file-exists-and-check-contents pattern, consistent with existing
`scanDbCredFiles` approach. `.netrc` deserves content parsing (look for
`password` or `login` tokens). `.npmrc` should check for `_authToken=`.
`.pypirc` should check for `password` under `[pypi]` section.
**Test:**
Add planted files to `tests/docker/planted/` and assertions to `validate.sh`.
---
### Finding 8: matchesExclude uses substring matching, not glob patterns
**Severity:** MINOR
**Axis:** Code Quality
**Files:** src/collectors/base.nim:90-94
**Issue:** `matchesExclude` checks `if pattern in path` — plain substring.
An exclude pattern of `"env"` would exclude `/home/user/.venv/something`,
`/home/user/environment/data`, and the intended `.env` file. The CLI help
says `--exclude <patterns>` suggesting glob behavior, but the implementation
is substring containment.
**Proof:** `matchesExclude("/home/user/.venv/lib/site.py", @["env"])`
returns `true`, excluding a Python virtualenv file that has nothing to do
with environment secrets.
**Proof Check:** Confidence: HIGH — `in` is Nim's substring containment
operator for strings.
**Fix:**
`src/collectors/base.nim:90-94` — use `std/os.extractFilename` and simple
glob matching, or at minimum document that patterns are substrings. Better
fix: use Nim's `std/strutils.contains` with path-segment awareness:
```nim
proc matchesExclude*(path: string, patterns: seq[string]): bool =
let name = path.extractFilename()
for pattern in patterns:
if pattern in name or pattern in path.splitPath().head:
return true
```
Or implement basic glob support with `*` matching.
**Test:**
Unit test that `.venv/lib/site.py` is NOT excluded by pattern `".env"`.
---
### Finding 9: JSON renderJson silently discards file-write errors
**Severity:** MINOR
**Axis:** Code Quality
**Files:** src/output/json.nim:72-85
**Issue:** When `--output <path>` specifies an invalid path (read-only dir,
nonexistent parent), `writeFile` throws, the exception is caught and
discarded. The JSON is then also written to stdout, but if stdout is
redirected and also fails, both errors are silently swallowed. The user
gets zero indication that their requested output file was not created.
**Proof:** Run `credenum --format json --output /root/nope.json` as
non-root — the file write fails silently, output goes only to stdout.
If stdout is piped to a broken pipe, both writes fail and the user
sees nothing.
**Proof Check:** Confidence: MEDIUM — the stdout fallback usually works,
so the practical impact is limited to the file path case.
**Fix:**
`src/output/json.nim:77-80` — write a warning to stderr on file write failure:
```nim
except CatchableError as e:
try:
stderr.writeLine "Warning: could not write to " & outputPath & ": " & e.msg
except CatchableError:
discard
```
**Test:**
```bash
just run --format json --output /dev/full 2>&1 | grep "Warning"
```
---
### Finding 10: redactLine strips leading quote but keeps trailing quote
**Severity:** MINOR
**Axis:** Code Quality
**Files:** src/collectors/history.nim:15-28
**Issue:** `redactLine` strips a leading `"` or `'` from the value via
`value[1 .. ^1]`, but `^1` is the last index in Nim (inclusive), so
this removes only the first character. Input `"secret"` becomes
`secret"` — the trailing quote survives into the redacted preview.
**Proof:** Input line `export API_KEY="mysecret"`:
1. `eqIdx` = 14 (position of `=`)
2. `value` = `"mysecret"` (after strip)
3. `value.startsWith("\"")` → true
4. `cleanValue` = `value[1 .. ^1]` = `mysecret"` (trailing quote kept)
5. `redactValue("mysecret\"", 4)` = `myse****"`
**Proof Check:** Confidence: HIGH — `^1` is the last character in Nim slice
notation; this is deterministic.
**Fix:**
`src/collectors/history.nim:24-26`:
```nim
let cleanValue = if (value.startsWith("\"") and value.endsWith("\"")) or
(value.startsWith("'") and value.endsWith("'")):
value[1 ..< ^1]
else:
value
```
Note: `^1` in `[1 ..< ^1]` excludes the last character (half-open range).
**Test:**
Unit test: `redactLine("export KEY=\"secret\"")` should produce `KEY=secr**`
with no trailing quote.
---
### Finding 11: isRelative computed but unused in Firefox profile parsing
**Severity:** MINOR
**Axis:** Code Quality
**Files:** src/collectors/browser.nim:11-48
**Issue:** The `scanFirefox` proc parses `IsRelative=0` from profiles.ini
and stores it in `isRelative`, but this variable is never read. Profile
path resolution uses `profile.startsWith("/")` instead. The variable is
dead code from an abandoned design path.
**Proof:** `isRelative` is set on lines 23 and 37, but never appears in
any conditional or expression after the parsing loop.
**Proof Check:** Confidence: HIGH — grep for `isRelative` in browser.nim
shows only assignments, zero reads.
**Fix:**
`src/collectors/browser.nim` — remove the `isRelative` variable entirely
(lines 23, 37). The `startsWith("/")` check on line 43 is sufficient for
Linux path detection.
**Test:**
```bash
just check
```
Verify compilation succeeds with no warnings about unused variable.
---
### Finding 12: Azure scanner adds directory finding unconditionally
**Severity:** MINOR
**Axis:** Code Quality
**Files:** src/collectors/cloud.nim:140-144
**Issue:** `scanAzure` always adds an svInfo finding for the Azure CLI
directory after checking for specific token files. If token cache findings
were already added, this creates redundant noise. If no tokens were found,
a bare directory finding at svInfo adds very little value.
**Proof:** If `~/.azure/` exists with `accessTokens.json`, the output shows:
1. "Azure token cache" at svMedium — useful
2. "Azure CLI configuration directory" at svInfo — noise, adds nothing
**Proof Check:** Confidence: MEDIUM — it's noise, not incorrect data. Could
argue the directory finding is useful as a "this user has Azure CLI installed"
signal, but only if no token files were found.
**Fix:**
`src/collectors/cloud.nim:140-144` — only add the directory finding if no
token files were found:
```nim
if result.findings.len == 0 or
result.findings[^1].category != catCloud:
result.findings.add(makeFinding(
azDir,
"Azure CLI configuration directory",
catCloud, svInfo
))
```
Better: track whether any Azure-specific findings were added and only emit
the directory finding as a fallback.
**Test:**
Docker test — verify Azure directory finding only appears when no token
findings exist.
---
## Self-Interrogation
Looking at these 12 findings as a whole:
- **Did I miss a dimension?** The tool has no rate-limiting or size-limiting on
file reads. `readFileContent` reads entire files into memory. A malicious
(or just large) `.bash_history` of several GB would cause OOM. But the
history scanner has `MaxHistoryLines = 50000` via `readFileLines`, which
mitigates this for its use case. Other collectors reading full files
(git config, kubeconfig) are typically small. Not worth a finding.
- **Are any findings weak?** Finding 12 (Azure directory) is the weakest —
it's a UX preference, not a bug. Keeping it as MINOR is appropriate.
Finding 11 (dead variable) is real but trivial. Everything MAJOR and above
is solid.
- **Completeness check:** The tool has 7 modules covering the major
categories but Finding 7 lists 8 specific credential stores that any
practitioner would expect. The `.netrc` omission alone is notable since
it's been the standard Unix credential store since the 1980s.
## Summary
**Total Findings:** 12 (2 critical, 5 major, 5 minor)
**Code Quality Findings:** 11
**Completeness Findings:** 1

View File

@ -1,257 +0,0 @@
# Credential Enumeration Tool — Design Spec
## Overview
A post-access credential enumeration tool written in Nim that scans Linux systems for exposed secrets across 7 categories. Compiles to a single static binary with zero dependencies — drop on target, run, get a structured report of every credential file, its exposure level, and severity rating.
**Language:** Nim 2.2.x
**Binary name:** `credenum`
**Architecture:** Modular collector pattern — one module per credential category, common interface, central runner
---
## Core Types (`src/types.nim`)
- **Severity** — enum: `info`, `low`, `medium`, `high`, `critical`
- **Category** — enum: `browser`, `ssh`, `cloud`, `history`, `keyring`, `git`, `apptoken`
- **Credential** — discovered credential data (source, credential type, value or redacted preview, metadata)
- **Finding** — a single discovery (path, category, severity, description, optional Credential, file permissions, timestamps)
- **CollectorResult**`seq[Finding]` + collector metadata (name, duration, errors encountered)
- **HarvestConfig** — runtime configuration (target home dir, enabled modules, exclude patterns, output format, flags)
- **Report** — all collector results + summary stats + timestamp + target info
**Severity assignment rules:**
- Critical: plaintext credentials in world-readable files
- High: unprotected private keys, plaintext credential stores
- Medium: overly permissive file permissions on credential files
- Low: credential files exist but properly permissioned
- Info: enumeration data (host lists, profile counts, existence checks)
---
## Collector Modules
Each module exports `proc collect(config: HarvestConfig): CollectorResult`. The runner calls each in sequence. No inheritance needed — just a common return type and a seq of collector procs populated at init.
### 1. Browser Credential Store Scanner (`src/collectors/browser.nim`)
- Firefox: locate profiles via `profiles.ini`, check `logins.json`, `cookies.sqlite`, `key4.db`
- Chromium: locate `Login Data`, `Cookies`, `Web Data` SQLite databases
- Report: file locations, permissions, entry counts, last-modified timestamps
- Flag world-readable/group-readable databases as critical
- Detection + metadata level (no decryption)
### 2. SSH Key & Config Auditor (`src/collectors/ssh.nim`)
- Scan `~/.ssh/` for private keys (RSA, Ed25519, ECDSA, non-standard filenames)
- Read key headers to determine passphrase protection (encrypted PEM vs unencrypted)
- Flag unprotected keys as high severity
- Check permissions (keys=600, directory=700)
- Parse `~/.ssh/config` — enumerate hosts, identify weak settings
- Read `authorized_keys` and `known_hosts` for enumeration
### 3. Cloud Provider Config Scanner (`src/collectors/cloud.nim`)
- AWS: `~/.aws/credentials`, `~/.aws/config` — count profiles, identify static vs session keys
- GCP: `~/.config/gcloud/` — application default credentials, service account keys
- Azure: `~/.azure/` — access tokens, profile info
- Kubernetes: `~/.kube/config` — enumerate contexts, clusters, auth methods
- Permission checks, flag anything broader than owner-only
### 4. Shell History & Environment Scanner (`src/collectors/history.nim`)
- Read `.bash_history`, `.zsh_history`, `.fish_history`
- Pattern match for inline secrets: KEY=, SECRET=, TOKEN=, PASSWORD= exports, DB connection strings, curl/wget with auth headers
- Scan for `.env` files in home directory tree
- Report: file, line region, redacted preview
### 5. Keyring & Password Store Scanner (`src/collectors/keyring.nim`)
- GNOME Keyring: `~/.local/share/keyrings/`
- KDE Wallet: `~/.local/share/kwalletd/`
- KeePass/KeePassXC: search for `.kdbx` files
- pass (password-store): `~/.password-store/`
- Bitwarden: `~/.config/Bitwarden/` local vault data
- Report locations, file sizes, permissions, last modified
### 6. Git Credential Store Scanner (`src/collectors/git.nim`)
- `~/.git-credentials` — plaintext storage (high severity)
- `~/.gitconfig` — check `credential.helper` setting
- Search for credential cache socket files
- Check for GitHub/GitLab PATs in config files
### 7. Application Token Scanner (`src/collectors/apptoken.nim`)
- Slack: `~/.config/Slack/` session/cookie storage
- Discord: `~/.config/discord/` token storage
- VS Code: `~/.config/Code/` stored secrets
- Database configs: `~/.pgpass`, `~/.my.cnf`, Redis configs
- MQTT broker configs, common application credential files
---
## CLI Interface
```
credenum [flags]
Flags:
--target <user> Target user home directory (default: current user)
--modules <list> Comma-separated module list (default: all)
--exclude <patterns> Glob patterns for paths to skip
--format <fmt> Output format: terminal, json, both (default: terminal)
--output <path> Write JSON output to file
--dry-run List paths that would be scanned without reading
--quiet Suppress banner and progress, output findings only
--verbose Show all scanned paths, not just findings
```
**CLI parsing:** `std/parseopt` (stdlib, no dependencies)
---
## Terminal Output Design
Hacker-aesthetic terminal output:
- ASCII art banner with tool name and version
- Box-drawing characters for section borders
- Color-coded severity badges (critical=red, high=magenta, medium=yellow, low=cyan, info=dim)
- Clean table formatting for findings
- Summary footer with totals by severity, modules scanned, duration
- Progress indicators showing which module is currently scanning
---
## Output Formats
### Terminal (ANSI)
Colored, formatted output designed for interactive use. Banner, per-module sections, severity badges, summary.
### JSON
Structured report:
```json
{
"metadata": { "timestamp": "...", "target": "...", "version": "...", "duration_ms": 0 },
"modules": [
{
"name": "ssh",
"findings": [
{
"category": "ssh",
"severity": "high",
"path": "/home/user/.ssh/id_rsa",
"description": "Unprotected private key (no passphrase)",
"permissions": "0644",
"modified": "2026-01-15T10:30:00Z"
}
],
"duration_ms": 12,
"errors": []
}
],
"summary": { "critical": 2, "high": 5, "medium": 8, "low": 3, "info": 12 }
}
```
---
## Build & Distribution
### Static binary via musl
- `config.nims` configures musl-gcc for fully static Linux binaries
- Zero runtime dependencies
### Cross-compilation
- x86_64-linux (primary)
- aarch64-linux (ARM64)
- Uses zig cc for cross-compilation
- Justfile tasks: `just build-x86`, `just build-arm64`
### Build modes
- `just build` — debug build with all checks
- `just release` — optimized static binary (`-d:release -d:lto --opt:size`)
- `just release-small` — stripped + UPX compressed
### Justfile tasks
- `just build` / `just release` / `just release-small`
- `just test` — run unit tests
- `just docker-test` — build + run in Docker test environment
- `just fmt` — format with nph
- `just clean`
---
## Docker Test Environment
**`tests/docker/Dockerfile`** — Ubuntu-based container planting fake credentials across all 7 categories:
- SSH: test key pairs (some protected, some not), various permissions
- Browser: mock Firefox profile with dummy `logins.json`, mock Chromium dirs
- Cloud: fake AWS credentials, dummy GCP service account JSON, mock kubeconfig
- History: seeded `.bash_history`/`.zsh_history` with fake tokens
- Keyrings: mock `.kdbx`, mock `pass` store
- Git: `.git-credentials` with dummy entries
- App tokens: mock Slack/Discord/VS Code configs, `.pgpass`, `.my.cnf`
All values are obviously fake (`AKIA_FAKE_ACCESS_KEY_12345`).
`just docker-test` builds, runs credenum inside, validates all findings discovered with correct severity.
---
## Project Structure
```
credential-enumeration/
├── src/
│ ├── harvester.nim # Entry point, CLI parsing
│ ├── config.nim # Constants, paths, patterns, severities
│ ├── types.nim # Core types
│ ├── runner.nim # Execute collectors, aggregate results
│ ├── output/
│ │ ├── terminal.nim # ANSI terminal output with hacker aesthetic
│ │ └── json.nim # JSON serialization
│ └── collectors/
│ ├── base.nim # Collector registration
│ ├── browser.nim
│ ├── ssh.nim
│ ├── cloud.nim
│ ├── history.nim
│ ├── keyring.nim
│ ├── git.nim
│ └── apptoken.nim
├── tests/
│ └── docker/
│ ├── Dockerfile
│ └── planted/ # Mock credential files
├── learn/
│ ├── 00-OVERVIEW.md
│ ├── 01-CONCEPTS.md
│ ├── 02-ARCHITECTURE.md
│ ├── 03-IMPLEMENTATION.md
│ └── 04-CHALLENGES.md
├── config.nims # Build config (static linking, cross-compile)
├── credential-enumeration.nimble # Package manifest
├── Justfile
├── install.sh
├── README.md
├── LICENSE
└── .gitignore
```
---
## Learn Folder
- **00-OVERVIEW.md** — What credential enumeration is, why it matters, prerequisites, quick start
- **01-CONCEPTS.md** — Linux credential storage locations, file permission model, where apps store secrets and why defaults are insecure. Real-world breach references.
- **02-ARCHITECTURE.md** — Modular collector design, data flow, why Nim for security tooling
- **03-IMPLEMENTATION.md** — Code walkthrough: core types, collector pattern, CLI parsing, output formatting, Nim type system and modules
- **04-CHALLENGES.md** — Extensions: new collectors, encrypted output, network enumeration, framework integration
---
## What This Project Teaches
- Linux credential storage locations across browsers, SSH, cloud tools, shells, keyrings, Git, and applications
- File permission models and their security implications
- Nim programming: static compilation, module system, type system, FFI potential
- Why Nim is adopted in the security assessment community (small static binaries, C-level performance)
- Modular tool architecture with common interfaces
- Building visually polished CLI tools
- Docker-based testing for security tools
- Cross-compilation and static linking for portable binaries