Metadata-Version: 2.5
Name: security-gym
Version: 0.6.0
Summary: Gymnasium environments for cybersecurity threat detection with continual learning
Project-URL: Homepage, https://github.com/j-klawson/security-gym
Project-URL: Documentation, https://j-klawson.github.io/security-gym
Project-URL: Repository, https://github.com/j-klawson/security-gym
Author: Keith Lawson
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: continual-learning,cybersecurity,gymnasium,reinforcement-learning
Classifier: Development Status :: 3 - Alpha
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Security
Requires-Python: >=3.11
Requires-Dist: gymnasium>=1.0.0
Requires-Dist: mmh3>=4.0.0
Requires-Dist: numpy>=1.24.0
Requires-Dist: pyyaml>=6.0
Provides-Extra: alberta
Requires-Dist: alberta-framework>=0.8.0; extra == 'alberta'
Requires-Dist: jax>=0.4.20; extra == 'alberta'
Requires-Dist: jaxlib>=0.4.20; extra == 'alberta'
Provides-Extra: all
Requires-Dist: alberta-framework>=0.8.0; extra == 'all'
Requires-Dist: jax>=0.4.20; extra == 'all'
Requires-Dist: jaxlib>=0.4.20; extra == 'all'
Requires-Dist: paramiko>=3.0.0; extra == 'all'
Requires-Dist: pytest>=9.0.3; extra == 'all'
Requires-Dist: requests>=2.28.0; extra == 'all'
Requires-Dist: ruff<0.17,>=0.16.1; extra == 'all'
Requires-Dist: scapy>=2.5.0; extra == 'all'
Requires-Dist: watchdog>=3.0.0; extra == 'all'
Provides-Extra: attacks
Requires-Dist: paramiko>=3.0.0; extra == 'attacks'
Requires-Dist: requests>=2.28.0; extra == 'attacks'
Requires-Dist: scapy>=2.5.0; extra == 'attacks'
Provides-Extra: collection
Requires-Dist: watchdog>=3.0.0; extra == 'collection'
Provides-Extra: dev
Requires-Dist: pytest>=9.0.3; extra == 'dev'
Requires-Dist: ruff<0.17,>=0.16.1; extra == 'dev'
Description-Content-Type: text/markdown

# security-gym

[![CI](https://github.com/j-klawson/security-gym/actions/workflows/ci.yml/badge.svg)](https://github.com/j-klawson/security-gym/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/security-gym)](https://pypi.org/project/security-gym/)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Gymnasium](https://img.shields.io/badge/Gymnasium-%E2%89%A51.0.0-blue)](https://gymnasium.farama.org/)
[![Dataset DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18901541.svg)](https://doi.org/10.5281/zenodo.18901541)
[![HF Dataset](https://img.shields.io/badge/%F0%9F%A4%97-Dataset-blue)](https://huggingface.co/datasets/j-klawson/security-gym-v4)

Gymnasium-compatible replay environment for security defense research. Labeled log and kernel event streams recorded from a live vulnerable host are replayed without episode boundaries. The agent observes raw text (like `tail -N` on log files and kernel event channels) and takes defensive actions (block, throttle, alert, isolate) that causally suppress future observations.

Security-Gym is trace-driven: the telemetry is recorded, not produced by a dynamics model. The simulated component is the defense layer (blocklist, throttle list, isolation mode), which masks the stream in response to the agent's actions. The loop is therefore closed on observability but not on adversary behavior, since blocking an attacker suppresses that attacker's events while the recorded attacker does not change tactics in response. Throughout this repository, "environment" refers to the software and its Gymnasium API, "corpus" or "dataset" to the released v4.1 data, and "benchmark" to the evaluation protocol together with the baseline results in [Baselines](#baselines).

Built for the [Alberta Plan](https://arxiv.org/abs/2208.11173) vision of long-lived agents that continually learn from non-stationary sensory streams.

![Security-Gym environment interaction loop](https://raw.githubusercontent.com/j-klawson/security-gym/main/figures/env-schematic.png)

*The continual-RL interaction loop. Telemetry sources (system logs and eBPF kernel events) are encoded as a Dict observation under one of two registered modes (Text or Hybrid). A streaming continual learner (no replay buffer; predict-then-update) emits a Dict action pairing a discrete defensive response with a continuous risk score, which updates a shared defense state (blocklist, throttle list, isolation mode). The dashed feedback edge captures the central difficulty of the domain: defensive actions causally suppress future observations on a continuous, non-episodic stream (`terminated = False`). The vector source is at [`figures/env-schematic.tex`](https://github.com/j-klawson/security-gym/blob/main/figures/env-schematic.tex).*

**Dataset access** — The v4 dataset is mirrored on HuggingFace ([`j-klawson/security-gym-v4`](https://huggingface.co/datasets/j-klawson/security-gym-v4), 2.8 GB compressed, 7d/30d/90d streams) and on Zenodo ([10.5281/zenodo.18901541](https://doi.org/10.5281/zenodo.18901541), 11.3 GB compressed, full release including the 365-day stream). MLCommons Croissant 1.0 metadata with Responsible AI fields ships at `data/croissant.json`.

## Features

- **Raw text observations (v1)** — 6 text channels (auth\_log, syslog, web\_log, process\_events, network\_events, file\_events) + numeric system stats. The agent learns its own representations.
- **Hybrid text + structured observations (v2)** — 3 text channels for logs + 3 fixed-width float32 arrays for eBPF kernel events. Matches how real SOC tooling consumes data: text for human-readable logs, structured arrays for kernel telemetry.
- **Defensive action space** — 6 actions (pass / alert / throttle / block\_source / unblock / isolate) + continuous risk score. Actions causally affect future observations.
- **Asymmetric rewards** — blocking an attacker earns +1.0, blocking a legitimate user costs -1.0. Ongoing consequence feedback from blocked/throttled events accumulates between steps.
- **Continuous stream** — `terminated` is always `False`; the log stream never ends (just like a real server)
- **eBPF kernel events** — process execution, network connections, and file access captured via BPF tracepoints. Mirrors how modern EDR agents work.
- **Attack framework** — YAML-driven campaign orchestrator with 6 modules: SSH brute force, credential stuffing, Log4Shell, Redis Lua sandbox escape (CVE-2022-0543), port scan, post-auth execution
- **Stream composition** — offline mixing of benign + attack data with Poisson-scheduled campaigns and MITRE ATT&CK-weighted type distributions

## Observation Space

### Text Mode (`SecurityLogStream-Text-v0`)

The agent sees the same data a security analyst would, raw log files and kernel event streams:

```
Dict({
    "auth_log":         Text    # SSH auth events (tail of /var/log/auth.log)
    "syslog":           Text    # System events (tail of /var/log/syslog)
    "web_log":          Text    # Combined web access/error logs
    "process_events":   Text    # eBPF: execve/exit kernel events
    "network_events":   Text    # eBPF: connect/accept socket events
    "file_events":      Text    # eBPF: open/unlink file events
    "system_stats":     Box(3)  # [load_avg, mem_used_frac, disk_used_frac]
    "defense_state":    Text    # Optional (defense_state_obs=True): own control state
})
```

Each text channel is a ring buffer of recent lines (configurable `tail_lines` and `max_chars`), updated on every step.

### Hybrid Mode (`SecurityLogStream-Hybrid-v0`)

Log channels remain as text; eBPF kernel events become fixed-width float32 arrays:

```
Dict({
    "auth_log":         Text              # Unchanged — raw log text
    "syslog":           Text              # Unchanged
    "web_log":          Text              # Unchanged
    "process_events":   Box(50, 8)        # [log_dt, pid, ppid, uid, syscall, comm_hash, parent_hash, tree_depth]
    "network_events":   Box(50, 7)        # [log_dt, pid, uid, syscall, dst_ip_hash, dst_port, comm_hash]
    "file_events":      Box(50, 6)        # [log_dt, pid, uid, syscall, flags, path_hash]
    "system_stats":     Box(3)            # Unchanged
    "defense_state":    Box(6)            # Optional (defense_state_obs=True)
})
```

Each structured channel is a ring buffer of `tail_events` rows (default 50). String fields (comm, IP, path) are hashed via mmh3 with per-field seeds. Timestamp deltas are log-scaled (`log(1 + dt)`) for gradient stability. Process events track tree depth from pid/ppid ancestry.

```python
env = gym.make("SecurityLogStream-Hybrid-v0", db_path="data/exp_7d_brute_v4.db", tail_events=50)
obs, info = env.reset()
print(obs["auth_log"][:100])            # str — raw log text
print(obs["process_events"].shape)      # (50, 8) — float32 array
```

### Defense State (`defense_state_obs=True`, opt-in)

Without this channel the environment reports only the host's telemetry, so an agent has no observable record of what it has already blocked, throttled, or isolated and must carry that state internally. An operator in the same position would run `fail2ban-client status` or `iptables -L`. Enabling `defense_state_obs` puts that answer in the observation.

Text mode renders it as command-style status output:

```
=== defense state ===
blocked (2): 10.0.0.5, 192.168.2.77
throttled (1): 198.51.100.2
isolation: off
events_suppressed: 1423
seconds_since_change: 84
current_src: 192.168.2.77 [BLOCKED]
```

Hybrid mode encodes the same facts as `Box(6)`: `[n_blocked, n_throttled, is_isolated, current_src_blocked, current_src_throttled, seconds_since_last_change]`. The address list has no fixed-width encoding, so the two membership flags carry the part that bears on the current decision.

The `current_src` field is what makes de-escalation decidable from the observation rather than from agent memory: it reports whether the source of the event now being observed is already under a control. Every field is a consequence of the agent's own actions, so the channel is ground-truth-blind and leaks no label.

The channel is default-off, which leaves the legacy observation space byte-for-byte unchanged. Whether it becomes the published default is an open decision (see TODO.md).

```python
env = gym.make("SecurityLogStream-Text-v0", db_path="data/exp_7d_brute_v4.db",
               defense_state_obs=True, block_visibility="deny_log")
obs, info = env.reset()
print(obs["defense_state"])
```

## Action Space

```
Dict({
    "action":     Discrete(6)   # 0=pass, 1=alert, 2=throttle, 3=block_source, 4=unblock, 5=isolate
    "risk_score": Box(0, 10)    # Agent's estimate of current threat level (auxiliary prediction)
    "target":     Discrete(65)  # 0=current event's source, 1..64=target slate slot
})
```

| Action | Effect |
|--------|--------|
| `pass` | Continue monitoring |
| `alert` | Flag for human review |
| `throttle` | Rate-limit source IP (~90% drop) |
| `block_source` | Add source IP to firewall blocklist (100% drop) |
| `unblock` | Remove source IP from blocklist/throttle list |
| `isolate` | Quarantine server (block all network events) |

The agent can escalate and de-escalate: throttle -> block -> unblock.

### Target selection

`target` names the address an action applies to. Index 0 is the current event's source, which was the only available binding before 0.6.0; indices 1 to `target_slate_size` name a slot in the target slate, an observable list of addresses seen anywhere in the stream.

This exists because binding actions to the current event forecloses most of the stream. 93.5% of `exp_30d_heavy_v4` events carry no source address at all, and that is structural rather than a parsing gap: 99.9% of address-less events are eBPF, and an `openat` or `execve` has no peer address to record. Without target selection an agent that infers compromise from a suspicious `execve` cannot act on it, because the event it is looking at names no host. With it, the agent blocks the host it saw earlier in `auth_log`.

The slate is its own observation channel, since an index into a list the agent cannot see is not learnable. Text mode renders it as a numbered list; hybrid mode encodes `Box(N, 3)` as `[addr_hash, is_blocked, is_throttled]`, hashing addresses with the same seed as the network channel's `dst_ip_hash` so a slate slot and a kernel event referring to one host hash alike.

```
=== targets ===
  0 current_src: none
  1 192.168.1.100 [BLOCKED]
  2 10.0.0.5
  3 -
```

Slots are stable, so a learned index keeps its referent. Eviction is least-recently-seen among addresses not under a control; blocked and throttled addresses are pinned, which is what keeps an already-blocked host nameable for release even under `"drop"` visibility, where its events stop arriving. If every slot is pinned, a newly seen address does not enter the slate and remains reachable through target 0 while it is the current event.

Two reward rules follow from targeting. An action that changes no state scores 0.0, rather than being paid in full for a block that dropped nothing. An action aimed at a slate address is graded on that address's last observed label rather than on whatever event is on screen, which is what makes it coherent to block a host named from kernel evidence while looking at an unrelated event; the grading uses only labels the agent has already been shown.

Set `target_slate_size=0` to restore the pre-0.6.0 action space.

### Block recovery (opt-in, default-off)

By default a blocked IP's events are dropped 100% and never reappear within an episode, so an `unblock` directed at it is unreachable (the IP is no longer observable). Two composable constructor parameters make the block then unblock loop learnable:

- `block_visibility="deny_log"` (default `"drop"`): a blocked IP's events are still dropped by the firewall, but each is surfaced in the observation as a ground-truth-blind `"[FIREWALL DENY] "` line. The agent can see a wrongly-blocked benign IP still active and `unblock` it (the deny line makes that IP the current event), or see an attacker go quiet and leave it blocked. The deny rendering never encodes `is_malicious`, so no label leaks into the observation.
- `block_ttl=<seconds>` (default `None`): fail2ban-style auto-expiry. A block lapses after `block_ttl` event-seconds; the IP re-surfaces for a fresh decision.

```python
env = gym.make("SecurityLogStream-Text-v0", db_path=..., block_visibility="deny_log", block_ttl=300.0)
```

## Reward Function

Two components by default (an action term and ongoing consequences), plus an optional risk-score term:

**Action reward** (asymmetric — mistakes in both directions are costly):

| Action | During Attack | During Benign |
|--------|--------------|---------------|
| `block_source` | +1.0 | -1.0 |
| `throttle` | +0.75 | -0.5 |
| `alert` | +0.5 | -0.3 |
| `pass` | -0.5 | 0.0 |
| `isolate` | +0.25 | -2.0 |
| `unblock` | -0.5 | 0.0 |

**Risk score MSE** (optional, *disabled by default*; enable via `reward_config={"include_risk_reward": True}`): `-0.1 * (predicted_risk - true_risk)^2` — penalizes inaccurate threat assessment. It is off by default because, as a per-event term that accumulates against every observed event, it can make observation-suppressing actions (block/throttle/isolate) rationally dominant.

**Ongoing consequences**: blocked/throttled events accumulate reward between steps (+0.1 per blocked attack event, -0.5 per blocked benign event). The agent feels the sustained cost of false positives.

Under `block_visibility="deny_log"`, a surfaced deny-log entry contributes *only* the consequence term: the per-step action and risk terms are zeroed so the event is not double-counted (the firewall, not the agent's live decision, is acting on it). The agent's action on a denied event is graded on the next live event it produces. Episode-total consequence reward is unchanged from `"drop"` mode; `"deny_log"` only redistributes it across steps (one step per blocked event) and lengthens the episode.

## Supported Attacks

| Attack Type | Module | MITRE Technique | MITRE Tactic | Description |
|---|---|---|---|---|
| `discovery` | `recon` | [T1046](https://attack.mitre.org/techniques/T1046/) — Network Service Discovery | TA0007 — Discovery | SYN port scan via scapy raw sockets |
| `brute_force` | `ssh_brute_force` | [T1110.001](https://attack.mitre.org/techniques/T1110/001/) — Password Guessing | TA0006 — Credential Access | SSH password brute force via paramiko with IP aliasing |
| `web_exploit` | `log4shell` | [T1190](https://attack.mitre.org/techniques/T1190/) — Exploit Public-Facing Application | TA0001 — Initial Access | Log4Shell (CVE-2021-44228) JNDI injection via HTTP |
| `credential_stuffing` | `credential_stuffing` | [T1110.004](https://attack.mitre.org/techniques/T1110/004/) — Credential Stuffing | TA0006 — Credential Access | Breach dump credentials, each tried once via SSH |
| `web_exploit` | `redis_lua_escape` | [T1190](https://attack.mitre.org/techniques/T1190/) — Exploit Public-Facing Application | TA0001 — Initial Access | Redis Lua sandbox escape ([CVE-2022-0543](https://nvd.nist.gov/vuln/detail/CVE-2022-0543), CVSS 10.0) — 3-stage: enum → Lua sandbox escape via `package.loadlib()` → post-exploit RCE |
| `execution` | `ssh_post_auth` | [T1059.004](https://attack.mitre.org/techniques/T1059/004/) — Unix Shell | TA0002 — Execution | Post-auth command execution + optional payload download |
| `persistence` | — | — | TA0003 — Persistence | Planned |
| `privilege_escalation` | — | — | TA0004 — Privilege Escalation | Planned |
| `exfiltration` | — | — | TA0010 — Exfiltration | Planned |

The first six attacks have implemented modules, campaign configs, and validated datasets. The remaining three tactics are planned and have no module, config, or data.

### Campaign Configurations

Nine YAML configs in `campaigns/` drive the six modules. Six are single-phase, isolating one module so its signature can be studied on its own; three chain multiple phases with distinct timing profiles per phase.

| Config | Phases | Notes |
|---|---|---|
| `recon_only.yaml` | recon | SYN scan from spoofed source IPs |
| `ssh_brute_only.yaml` | ssh_brute_force | Aliased IPs, accelerating timing profile |
| `credential_stuffing_only.yaml` | credential_stuffing | Unique pairs tried once each |
| `log4shell_only.yaml` | log4shell | Decelerating profile (spray, then slow to evade) |
| `redis_exploit_only.yaml` | redis_lua_escape | Custom profile: slow enum, fast exploit, slow post-exploit |
| `post_auth_only.yaml` | ssh_post_auth | Valid credentials, system profiling commands |
| `recon_ssh_log4shell.yaml` | recon -> ssh_brute_force -> log4shell | Largest campaign in the dataset |
| `full_killchain.yaml` | recon -> credential_stuffing -> ssh_post_auth | Ends in a successful shell |
| `redis_killchain.yaml` | recon -> redis_lua_escape -> ssh_post_auth | Redis RCE, then SSH pivot |

Run one with `python -m attacks run campaigns/<config>.yaml` (see [Running Attack Campaigns](#running-attack-campaigns)). The shipped `campaigns_v2.db` holds the recorded output of these configs: 60,468 events, of which 30,436 are labeled malicious by time and source-IP matching. The remainder is target-host background and collector activity captured inside the same collection windows, not a benign traffic baseline; the benign baseline comes from `benign_v4.db` at composition time. Per-campaign counts and known limitations are documented in `data/DATASET_README.md`.

### Redis Lua Sandbox Escape (CVE-2022-0543)

The `redis_lua_escape` module exploits a Debian-specific vulnerability where Redis is dynamically linked against liblua5.1, allowing `package.loadlib()` to escape the Lua sandbox for unauthenticated RCE. The attack runs in three stages:

1. **Enumeration** — fingerprint Redis via `INFO`, `CONFIG GET *`, `DBSIZE`, `CLIENT LIST`
2. **Exploitation** — Lua sandbox escape via `EVAL` + `package.loadlib("/usr/lib/x86_64-linux-gnu/liblua5.1.so.0", "luaopen_io")`
3. **Post-exploitation** — system commands via repeated `EVAL` calls (`id`, `whoami`, then configurable command profiles)

**Key eBPF detection signal:** `execve` events where `parent_comm=redis-server` — Redis spawning shell commands (`sh`, `bash`, `id`, `cat`) is highly anomalous. The eBPF collector captures `ppid` + `parent_comm` on every process event, so this parent-child relationship appears directly in the `process_events` text channel.

## Baselines

Baseline agents establish performance bounds for the environment. See `examples/` for runnable scripts. Results below are from `exp_30d_heavy_v4.db` (1M steps, seed 42, all 5 attack types), regenerated for 0.6.0.

| Agent | Description | Precision | Recall | F1 | Mean Reward |
|-------|-------------|----------:|-------:|---:|------------:|
| **pass-only** | Never acts — always passes | 0.000 | 0.000 | 0.000 | -0.0120 |
| **random** | Samples the full action space | 0.005 | 0.675 | 0.010 | -0.4061 |
| **threshold(5)** | Block IP after 5 failed SSH auths in 5 min | 1.000 | 0.005 | 0.011 | +0.0001 |
| **keyword** | Multi-channel SIEM-style pattern matching | 0.987 | 0.013 | 0.025 | -0.0115 |
| **rlsecd** | 5-head MLP continual learner ([rlsecd](https://github.com/j-klawson/rlsecd)) | 0.979 | 0.979 | 0.979 | — |

Reported under the 0.6.0 defaults: `include_risk_reward=False`, `target_slate_size=64`, `defense_state_obs=True`. The mean-reward column moved substantially from the pre-0.6.0 table for two independent reasons, and the detection columns barely moved at all, since they never depended on the reward.

The larger reason predates this release. The previous table was generated while `include_risk_reward` still defaulted to `True`, and was not regenerated when commit `a5ceae9` flipped that default off. Its pass-only figure of -0.073 is reproduced exactly by adding the dormant risk-MSE term back (-0.0733 computed directly from the first 1M events), so that column had been describing a reward function the environment no longer computed. The smaller reason is this release: an action that changes no state now scores 0.0 instead of being paid in full, which matters because ~93.5% of steps carry no address for an action to bind to.

Both heuristic agents achieve high precision but near-zero recall. The threshold agent only sees SSH brute force in auth\_log text. The keyword agent adds rules for Log4Shell, process ancestry, and file access across all channels, doubling F1 — but still catches only 1.3% of attacks because most malicious events are eBPF kernel telemetry (file opens, process exits, network accepts) that don't match static signatures. Only a learning agent can generalize across the full observation space.

```bash
# Run all baselines on an experiment stream
python examples/benchmark.py data/exp_7d_brute_v4.db

# Individual agents
python examples/random_agent.py data/exp_7d_brute_v4.db
python examples/threshold_agent.py data/exp_7d_brute_v4.db --threshold 5
python examples/keyword_agent.py data/exp_7d_brute_v4.db
python examples/streaming_demo.py data/exp_7d_brute_v4.db --mode gym
```

## Install

```bash
pip install security-gym
```

Or from source:

```bash
git clone https://github.com/j-klawson/security-gym.git
cd security-gym
pip install -e ".[dev]"
```

Optional extras:

```bash
pip install -e ".[alberta]"   # JAX + alberta-framework for RL experiments
pip install -e ".[attacks]"   # paramiko, requests, scapy for attack generation
pip install -e ".[all]"       # Everything
```

## Dataset

Pre-built datasets (SQLite databases with labeled log and eBPF kernel events) are available from [Zenodo](https://doi.org/10.5281/zenodo.18901541) and [GitHub Releases](https://github.com/j-klawson/security-gym/releases).

The v4 dataset includes 11.2M benign events (7.9M logs + 3.24M eBPF from 3 servers) and 60K attack events across 5 attack types. Pre-composed experiment streams range from 4.9M events (7-day) to 257.7M events (365-day).

Download the latest dataset:

```bash
# Via CLI (after pip install)
security-gym download

# Or list available releases first
security-gym list
```

Or download from [Zenodo](https://doi.org/10.5281/zenodo.18901541) and decompress with `zstd -d <file>.zst` into `data/`.

## Quick Start

### Basic Gymnasium Usage

```python
import gymnasium as gym
import numpy as np
import security_gym

env = gym.make("SecurityLogStream-Text-v0", db_path="data/exp_7d_brute_v4.db")
obs, info = env.reset()

# obs is a dict of text channels + system stats
print(obs["auth_log"][:200])   # Raw auth log lines
print(obs["system_stats"])     # [load_avg, mem_used, disk_used]

while True:
    # Choose an action
    action = {
        "action": 0,  # pass (monitor only)
        "risk_score": np.array([0.0], dtype=np.float32),
    }

    obs, reward, terminated, truncated, info = env.step(action)

    # Ground truth (for evaluation, not visible to agent)
    gt = info["ground_truth"]
    print(f"{info['timestamp']} | malicious={gt['is_malicious']} | "
          f"risk={gt['true_risk']:.1f} | reward={reward:.2f}")

    if truncated:  # End of data
        break
```

### Defensive Actions

```python
import numpy as np

# Block the current event's source IP (100% drop)
block = {"action": 3, "risk_score": np.array([8.0], dtype=np.float32)}

# Throttle (90% drop rate)
throttle = {"action": 2, "risk_score": np.array([5.0], dtype=np.float32)}

# Alert with high risk estimate
alert = {"action": 1, "risk_score": np.array([7.0], dtype=np.float32)}

# Undo a block (correct false positive)
unblock = {"action": 4, "risk_score": np.array([1.0], dtype=np.float32)}

# Quarantine server (blocks all network events)
isolate = {"action": 5, "risk_score": np.array([10.0], dtype=np.float32)}
```

After blocking an IP, future events from that IP are silently dropped. The agent observes the absence of those events and receives ongoing consequence feedback:
- Dropped attack events: +0.1 per event (confirmed mitigation)
- Dropped benign events: -0.5 per event (service impact)

### ANSI Rendering

```python
env = gym.make("SecurityLogStream-Text-v0", db_path="data/exp_7d_brute_v4.db", render_mode="ansi")
obs, info = env.reset()
for _ in range(20):
    action = {"action": 0, "risk_score": np.array([0.0], dtype=np.float32)}
    obs, reward, terminated, truncated, info = env.step(action)
    print(env.render())  # Color-coded: red=malicious, green=benign
```

### SecurityGymStream (Batch/Streaming Adapter)

For direct integration with learning frameworks (bypasses Gymnasium overhead):

```python
from security_gym.adapters.scan_stream import SecurityGymStream

stream = SecurityGymStream("data/exp_7d_brute_v4.db")

# Batch: load all observations and ground truth
observations, ground_truths = stream.collect_numpy()
# observations: list of dicts (one per event, each with text channels + system_stats)
# ground_truths: list of dicts (is_malicious, attack_type, true_risk, ...)

# Constant-memory streaming
for obs_batch, gt_batch in stream.iter_batches(size=1000):
    for obs, gt in zip(obs_batch, gt_batch):
        print(obs["auth_log"][:80], gt["is_malicious"])

# Server-speed evaluation mode (never-ending, paced stream)
stream = SecurityGymStream("data/exp_7d_brute_v4.db", speed=10.0, loop=True)
for timestep in stream:  # Requires JAX
    ...
```

## Generating Data

### Running Attack Campaigns

The attack framework generates labeled data by executing scripted attacks against a target VM and collecting the resulting logs:

```bash
# List available attack modules
python -m attacks list-modules

# Validate a campaign config
python -m attacks validate campaigns/ssh_brute_only.yaml

# Dry run (preview without executing)
python -m attacks run campaigns/ssh_brute_only.yaml --dry-run

# Execute (requires network access to target VM)
sudo python -m attacks run campaigns/ssh_brute_only.yaml
```

Campaign configs are YAML files defining attack phases, timing profiles, IP strategies, and log collection:

```yaml
campaign:
  name: "SSH Brute Force Only"
  seed: 42
  target:
    host: 192.168.2.201
    ssh_user: researcher
    ssh_key: ~/.ssh/your_public_key
  collection:
    ebpf:
      enabled: true           # Collect kernel events via eBPF
  phases:
    - name: "SSH Brute Force"
      module: ssh_brute_force
      mitre_technique: "T1110.001"
      params:
        usernames: ["root", "admin", "ubuntu"]
        passwords: ["password", "123456", "admin"]
        target_port: 22
        max_attempts_per_ip: 10
      ip_source:
        strategy: aliased
        count: 5
        subnet: "192.168.2.0/24"
      timing:
        duration_seconds: 300
        profile: constant
        jitter_ms: [200, 800]
```

### Importing Benign Logs

Import real server logs as baseline benign data:

```bash
python -m attacks import-logs server_logs.tar --db data/benign.db --host myserver
```

### Collecting eBPF Kernel Events

The three kernel observation channels (`process_events`, `network_events`, `file_events`) are populated by an eBPF collector daemon that attaches to Linux kernel tracepoints via [BCC](https://github.com/iovisor/bcc). This captures syscall-level activity invisible to traditional log files — the agent sees process execution chains, network connections, and file access as they happen in the kernel.

**What's captured:**

| Channel | Tracepoints | Fields |
|---------|------------|--------|
| `process_events` | `sys_enter_execve`, `sched_process_exit` | pid, ppid, uid, comm, parent\_comm, args, exit code |
| `network_events` | `sys_enter_connect`, `sys_enter_accept4` | pid, uid, comm, dst IP:port |
| `file_events` | `sys_enter_openat`, `sys_enter_unlinkat` | pid, comm, path, flags |

Process events include **parent process ancestry** (ppid + parent\_comm), allowing the agent to learn causal chains — e.g., `apache2 → wget` is suspicious while `cron → wget` may be routine. Network events include the **effective UID**, so the agent can learn user-identity-aware policies.

**Benign baseline collection:**

eBPF kernel events are collected from the target server during normal operation (no attacks running) to establish a baseline of benign system activity:

```bash
# Collect 1 hour of benign kernel events from the target server
python scripts/collect_ebpf_baseline.py --duration 3600

# Preview without collecting
python scripts/collect_ebpf_baseline.py --duration 3600 --dry-run
```

This SSHs into the target, deploys the eBPF collector, runs for the specified duration, retrieves the events, and inserts them as benign (`is_malicious=0`). The v4 benign dataset includes 3.24M eBPF events from 24-hour collections on 3 servers.

**During attack campaigns:**

When `ebpf: {enabled: true}` is set in a campaign YAML, the orchestrator automatically starts the eBPF collector before the attack begins and stops it after. Kernel events captured during attack windows are labeled malicious through the same time+IP matching used for log events — an `execve wget` from the attacker's IP during an attack phase is correctly labeled as part of the attack.

### Composing Experiment Streams

Combine benign and attack data into reproducible experiment streams:

```bash
# Preview composition plan
python -m attacks compose configs/stream_90d_mixed.yaml --dry-run

# Generate composed stream
python -m attacks compose configs/stream_90d_mixed.yaml
```

Composition configs control duration, attack frequency, and MITRE ATT&CK-weighted type distributions:

```yaml
stream:
  duration: 90d
  seed: 42
  benign:
    db: data/benign_v4.db
    ebpf_sample_rate: 0.242    # 24.2%: approximates a single host's event rate
  attacks:
    db: data/campaigns_v2.db
    campaigns_per_day: 3.0
    distribution:
      discovery: 0.35
      brute_force: 0.30
      web_exploit: 0.20
      credential_stuffing: 0.10
      execution: 0.05
  output:
    db: data/exp_90d_v4.db
```

## Project Structure

```
security-gym/
├── src/security_gym/          # Installable package
│   ├── adapters/              # SecurityGymStream (batch/streaming adapter)
│   ├── data/                  # EventStore (SQLite), StreamComposer
│   ├── envs/                  # SecurityLogStreamEnv (v1, v2), deprecated wrappers
│   ├── features/              # Deprecated (v0 numeric extractors)
│   ├── parsers/               # auth_log, syslog, web_access, web_error, journal, ebpf
│   └── targets/               # Deprecated (v0 multi-head target builder)
├── examples/                  # Baseline agents and usage demos
├── attacks/                   # Attack framework (NOT pip-installed)
│   ├── modules/               # recon, ssh_brute_force, credential_stuffing, ssh_post_auth, log4shell, redis_lua_escape
│   ├── collection/            # SSH/SFTP log collector, benign log importer, eBPF orchestrator
│   ├── labeling/              # Time+IP campaign labeler
│   └── tests/                 # Attack framework tests
├── campaigns/                 # YAML campaign configs
├── configs/                   # YAML composition configs
├── server/                    # Target VM provisioning docs, eBPF collector daemon
└── tests/                     # Core package tests
```

## Development

```bash
pip install -e ".[dev]"
pytest tests/                     # Core tests (339 tests)
pytest attacks/tests/             # Attack framework tests (120 tests)
ruff check src/ tests/ attacks/   # Lint
```

## Requirements

- Python >= 3.11
- gymnasium >= 1.0.0
- numpy >= 1.24.0

## Author

Keith Lawson

## License

Apache-2.0
