diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/.gitignore b/PROJECTS/beginner/linux-ebpf-security-tracer/.gitignore new file mode 100644 index 00000000..7fc68866 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/.gitignore @@ -0,0 +1,13 @@ +docs/ +__pycache__/ +*.pyc +.env +.venv/ +*.o +*.so +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +dist/ +build/ +*.egg-info/ diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/.style.yapf b/PROJECTS/beginner/linux-ebpf-security-tracer/.style.yapf new file mode 100644 index 00000000..5f3a946c --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/.style.yapf @@ -0,0 +1,3 @@ +[style] +based_on_style = pep8 +column_limit = 75 diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/README.md b/PROJECTS/beginner/linux-ebpf-security-tracer/README.md new file mode 100644 index 00000000..c562ea44 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/README.md @@ -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 diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/install.sh b/PROJECTS/beginner/linux-ebpf-security-tracer/install.sh new file mode 100755 index 00000000..e9005d7c --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/install.sh @@ -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 "$@" diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/justfile b/PROJECTS/beginner/linux-ebpf-security-tracer/justfile new file mode 100644 index 00000000..51b150f6 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/justfile @@ -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 diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/learn/00-OVERVIEW.md b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/00-OVERVIEW.md new file mode 100644 index 00000000..b07a50c9 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/00-OVERVIEW.md @@ -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 diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/learn/01-CONCEPTS.md b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/01-CONCEPTS.md new file mode 100644 index 00000000..57e46d6d --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/01-CONCEPTS.md @@ -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 diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/learn/02-ARCHITECTURE.md b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/02-ARCHITECTURE.md new file mode 100644 index 00000000..b6c29442 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/02-ARCHITECTURE.md @@ -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//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//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. diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/learn/03-IMPLEMENTATION.md b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/03-IMPLEMENTATION.md new file mode 100644 index 00000000..13e854f6 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/03-IMPLEMENTATION.md @@ -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. diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/learn/04-CHALLENGES.md b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/04-CHALLENGES.md new file mode 100644 index 00000000..ba3597e1 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/learn/04-CHALLENGES.md @@ -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//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//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//cgroup` to detect container membership +- Docker containers have cgroup paths like `/docker/` +- Kubernetes pods have paths like `/kubepods/pod/` +- 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: `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 diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/pyproject.toml b/PROJECTS/beginner/linux-ebpf-security-tracer/pyproject.toml new file mode 100644 index 00000000..0e15bf2d --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/pyproject.toml @@ -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/", +] diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/__init__.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/config.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/config.py new file mode 100644 index 00000000..c083bad9 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/config.py @@ -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"), + ), +} diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/detector.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/detector.py new file mode 100644 index 00000000..d6d72688 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/detector.py @@ -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 diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/__init__.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/file_tracer.c b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/file_tracer.c new file mode 100644 index 00000000..68670669 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/file_tracer.c @@ -0,0 +1,97 @@ +// ©AngelaMos | 2026 +// file_tracer.c + +#include +#include + +#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; +} diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/network_tracer.c b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/network_tracer.c new file mode 100644 index 00000000..34dff556 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/network_tracer.c @@ -0,0 +1,121 @@ +// ©AngelaMos | 2026 +// network_tracer.c + +#include +#include +#include +#include + +#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; +} diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/privilege_tracer.c b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/privilege_tracer.c new file mode 100644 index 00000000..4f61d6d4 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/privilege_tracer.c @@ -0,0 +1,80 @@ +// ©AngelaMos | 2026 +// privilege_tracer.c + +#include +#include + +#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; +} diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/process_tracer.c b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/process_tracer.c new file mode 100644 index 00000000..07c759ae --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/process_tracer.c @@ -0,0 +1,80 @@ +// ©AngelaMos | 2026 +// process_tracer.c + +#include +#include + +#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; +} diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/system_tracer.c b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/system_tracer.c new file mode 100644 index 00000000..354c989b --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/ebpf/system_tracer.c @@ -0,0 +1,94 @@ +// ©AngelaMos | 2026 +// system_tracer.c + +#include +#include + +#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; +} diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/loader.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/loader.py new file mode 100644 index 00000000..cca2a579 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/loader.py @@ -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() diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/main.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/main.py new file mode 100644 index 00000000..921840cf --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/main.py @@ -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[/]") diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/processor.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/processor.py new file mode 100644 index 00000000..f48ca289 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/processor.py @@ -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 diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/src/renderer.py b/PROJECTS/beginner/linux-ebpf-security-tracer/src/renderer.py new file mode 100644 index 00000000..c5cf6771 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/src/renderer.py @@ -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() diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/testdata/sample_events.json b/PROJECTS/beginner/linux-ebpf-security-tracer/testdata/sample_events.json new file mode 100644 index 00000000..a045e543 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/testdata/sample_events.json @@ -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 + } +] diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/tests/__init__.py b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/tests/conftest.py b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/conftest.py new file mode 100644 index 00000000..7a5433be --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/conftest.py @@ -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 diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_detector.py b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_detector.py new file mode 100644 index 00000000..649a272c --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_detector.py @@ -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" diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_processor.py b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_processor.py new file mode 100644 index 00000000..3964db04 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_processor.py @@ -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) diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_renderer.py b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_renderer.py new file mode 100644 index 00000000..eb1313d3 --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/tests/test_renderer.py @@ -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 diff --git a/PROJECTS/beginner/linux-ebpf-security-tracer/uv.lock b/PROJECTS/beginner/linux-ebpf-security-tracer/uv.lock new file mode 100644 index 00000000..6488acde --- /dev/null +++ b/PROJECTS/beginner/linux-ebpf-security-tracer/uv.lock @@ -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" }, +] diff --git a/docs/plans/2026-04-01-credential-enumeration-audit-fixes.md b/docs/plans/2026-04-01-credential-enumeration-audit-fixes.md deleted file mode 100644 index e4545b73..00000000 --- a/docs/plans/2026-04-01-credential-enumeration-audit-fixes.md +++ /dev/null @@ -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 ` 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 ` 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 diff --git a/docs/superpowers/specs/2026-04-01-credential-enumeration-design.md b/docs/superpowers/specs/2026-04-01-credential-enumeration-design.md deleted file mode 100644 index 959bca97..00000000 --- a/docs/superpowers/specs/2026-04-01-credential-enumeration-design.md +++ /dev/null @@ -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 Target user home directory (default: current user) - --modules Comma-separated module list (default: all) - --exclude Glob patterns for paths to skip - --format Output format: terminal, json, both (default: terminal) - --output 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