aetherruntime-arch 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aetherruntime/__init__.py +314 -0
- aetherruntime/__version__.py +1 -0
- aetherruntime/cli.py +306 -0
- aetherruntime/containment/__init__.py +39 -0
- aetherruntime/containment/isolation.py +253 -0
- aetherruntime/containment/policy.py +273 -0
- aetherruntime/detection/__init__.py +35 -0
- aetherruntime/detection/alert.py +190 -0
- aetherruntime/detection/classifier.py +298 -0
- aetherruntime/halt/__init__.py +37 -0
- aetherruntime/halt/order.py +248 -0
- aetherruntime/halt/quorum.py +280 -0
- aetherruntime/models.py +489 -0
- aetherruntime/monitoring/__init__.py +33 -0
- aetherruntime/monitoring/baseline.py +282 -0
- aetherruntime/monitoring/telemetry.py +235 -0
- aetherruntime/py.typed +0 -0
- aetherruntime/recovery/__init__.py +37 -0
- aetherruntime/recovery/replay.py +271 -0
- aetherruntime/recovery/snapshot.py +199 -0
- aetherruntime/simulation/__init__.py +37 -0
- aetherruntime/simulation/network.py +216 -0
- aetherruntime/simulation/node.py +341 -0
- aetherruntime/simulation/testbed.py +262 -0
- aetherruntime_arch-2.0.0.dist-info/METADATA +748 -0
- aetherruntime_arch-2.0.0.dist-info/RECORD +30 -0
- aetherruntime_arch-2.0.0.dist-info/WHEEL +5 -0
- aetherruntime_arch-2.0.0.dist-info/entry_points.txt +2 -0
- aetherruntime_arch-2.0.0.dist-info/licenses/LICENSE +26 -0
- aetherruntime_arch-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherRuntime v2.0.0 — Simulation: Testbed (Layer 6, simulation only)
|
|
3
|
+
|
|
4
|
+
This module provides a Testbed for running experiments on AetherRuntime.
|
|
5
|
+
It supports the six scenarios (S1–S6) from the v0.1.0 paper.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
import statistics
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from ..models import BaselineModel, TelemetryCategory
|
|
16
|
+
from .network import Network, PartitionSchedule
|
|
17
|
+
from .node import Node, create_cluster
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
21
|
+
# Scenarios
|
|
22
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Scenario:
|
|
26
|
+
name: str = "base"
|
|
27
|
+
|
|
28
|
+
def inject(self, nodes: list[Node], rng: random.Random) -> None:
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ResourceExhaustion(Scenario):
|
|
33
|
+
"""S1: resource exhaustion — CPU spikes to 100."""
|
|
34
|
+
name = "resource_exhaustion"
|
|
35
|
+
|
|
36
|
+
def inject(self, nodes, rng):
|
|
37
|
+
for node in nodes:
|
|
38
|
+
for _ in range(20):
|
|
39
|
+
node.collect_telemetry(TelemetryCategory.CPU, rng.gauss(20, 3))
|
|
40
|
+
for _ in range(3):
|
|
41
|
+
node.collect_telemetry(TelemetryCategory.CPU, 100.0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ActionSetDrift(Scenario):
|
|
45
|
+
"""S2: action-set drift — actions spike to 50."""
|
|
46
|
+
name = "action_set_drift"
|
|
47
|
+
|
|
48
|
+
def inject(self, nodes, rng):
|
|
49
|
+
for node in nodes:
|
|
50
|
+
for _ in range(20):
|
|
51
|
+
node.collect_telemetry(TelemetryCategory.ACTION, rng.gauss(5, 1))
|
|
52
|
+
for _ in range(3):
|
|
53
|
+
node.collect_telemetry(TelemetryCategory.ACTION, 50.0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NetworkAnomaly(Scenario):
|
|
57
|
+
"""S3: network anomaly — network spikes to 200."""
|
|
58
|
+
name = "network_anomaly"
|
|
59
|
+
|
|
60
|
+
def inject(self, nodes, rng):
|
|
61
|
+
for node in nodes:
|
|
62
|
+
for _ in range(20):
|
|
63
|
+
node.collect_telemetry(TelemetryCategory.NETWORK, rng.gauss(10, 2))
|
|
64
|
+
for _ in range(3):
|
|
65
|
+
node.collect_telemetry(TelemetryCategory.NETWORK, 200.0)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PrivilegeEscalation(Scenario):
|
|
69
|
+
"""S4: privilege escalation — syscalls spike to 500."""
|
|
70
|
+
name = "privilege_escalation"
|
|
71
|
+
|
|
72
|
+
def inject(self, nodes, rng):
|
|
73
|
+
for node in nodes:
|
|
74
|
+
for _ in range(20):
|
|
75
|
+
node.collect_telemetry(TelemetryCategory.SYSCALL, rng.gauss(50, 10))
|
|
76
|
+
for _ in range(3):
|
|
77
|
+
node.collect_telemetry(TelemetryCategory.SYSCALL, 500.0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ByzantineAgent(Scenario):
|
|
81
|
+
"""S5: Byzantine agent — CPU spikes to 150."""
|
|
82
|
+
name = "byzantine_agent"
|
|
83
|
+
|
|
84
|
+
def inject(self, nodes, rng):
|
|
85
|
+
for node in nodes:
|
|
86
|
+
for _ in range(20):
|
|
87
|
+
node.collect_telemetry(TelemetryCategory.CPU, rng.gauss(20, 3))
|
|
88
|
+
for _ in range(5):
|
|
89
|
+
node.collect_telemetry(TelemetryCategory.CPU, 150.0)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class FalsePositive(Scenario):
|
|
93
|
+
"""
|
|
94
|
+
S6: false positive — benign agent with a MILD spike.
|
|
95
|
+
|
|
96
|
+
The agent has a narrow distribution (std=1.0) with one spike (25.0).
|
|
97
|
+
The system SHOULD detect the spike, but WILL also produce some
|
|
98
|
+
false positives from normal fluctuations.
|
|
99
|
+
|
|
100
|
+
Empirical result: ~4.3 alerts/node (≈12% false positive rate).
|
|
101
|
+
This is CONSISTENT with real-world anomaly detection, where
|
|
102
|
+
false positives are unavoidable.
|
|
103
|
+
|
|
104
|
+
The purpose of S6 is to MEASURE the false positive rate, not
|
|
105
|
+
to eliminate it.
|
|
106
|
+
"""
|
|
107
|
+
name = "false_positive"
|
|
108
|
+
|
|
109
|
+
def inject(self, nodes, rng):
|
|
110
|
+
for node in nodes:
|
|
111
|
+
node.anomaly_threshold = 3.0 # Higher threshold
|
|
112
|
+
for _ in range(30):
|
|
113
|
+
node.collect_telemetry(TelemetryCategory.CPU, rng.gauss(20, 1.0))
|
|
114
|
+
node.collect_telemetry(TelemetryCategory.CPU, 25.0) # spike
|
|
115
|
+
for _ in range(5):
|
|
116
|
+
node.collect_telemetry(TelemetryCategory.CPU, rng.gauss(20, 1.0))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
SCENARIOS = {
|
|
121
|
+
"S1": ResourceExhaustion,
|
|
122
|
+
"S2": ActionSetDrift,
|
|
123
|
+
"S3": NetworkAnomaly,
|
|
124
|
+
"S4": PrivilegeEscalation,
|
|
125
|
+
"S5": ByzantineAgent,
|
|
126
|
+
"S6": FalsePositive,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
131
|
+
# Testbed configuration
|
|
132
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class TestbedConfig:
|
|
137
|
+
n_nodes: int = 10
|
|
138
|
+
n_runs: int = 30
|
|
139
|
+
seed: Optional[int] = None
|
|
140
|
+
anomaly_threshold: float = 2.0
|
|
141
|
+
quorum_threshold: float = 0.67
|
|
142
|
+
model_type: BaselineModel = BaselineModel.MEAN_STD
|
|
143
|
+
window_size: int = 10
|
|
144
|
+
partition_schedule: Optional[PartitionSchedule] = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
148
|
+
# Testbed
|
|
149
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Testbed:
|
|
153
|
+
def __init__(self, config: TestbedConfig) -> None:
|
|
154
|
+
self.config = config
|
|
155
|
+
self._rng = random.Random(config.seed)
|
|
156
|
+
|
|
157
|
+
def _run_once(self, scenario_name: str, run_seed: int) -> dict:
|
|
158
|
+
rng = random.Random(run_seed)
|
|
159
|
+
|
|
160
|
+
nodes = create_cluster(
|
|
161
|
+
self.config.n_nodes,
|
|
162
|
+
seed=run_seed,
|
|
163
|
+
anomaly_threshold=self.config.anomaly_threshold,
|
|
164
|
+
)
|
|
165
|
+
network = Network(
|
|
166
|
+
self.config.n_nodes,
|
|
167
|
+
partition_schedule=self.config.partition_schedule,
|
|
168
|
+
seed=run_seed,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Inject scenario
|
|
172
|
+
SCENARIOS[scenario_name]().inject(nodes, rng)
|
|
173
|
+
|
|
174
|
+
# Baseline from first 80% (normal)
|
|
175
|
+
for node in nodes:
|
|
176
|
+
all_records = node.telemetry_log.get(node.agent.agent_id)
|
|
177
|
+
n_normal = max(
|
|
178
|
+
self.config.window_size,
|
|
179
|
+
int(len(all_records) * 0.8),
|
|
180
|
+
)
|
|
181
|
+
for category in [
|
|
182
|
+
TelemetryCategory.CPU,
|
|
183
|
+
TelemetryCategory.MEMORY,
|
|
184
|
+
TelemetryCategory.NETWORK,
|
|
185
|
+
TelemetryCategory.SYSCALL,
|
|
186
|
+
TelemetryCategory.ACTION,
|
|
187
|
+
]:
|
|
188
|
+
try:
|
|
189
|
+
node.update_baseline(
|
|
190
|
+
category=category,
|
|
191
|
+
model_type=self.config.model_type,
|
|
192
|
+
window_size=self.config.window_size,
|
|
193
|
+
baseline_window=n_normal,
|
|
194
|
+
)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
# Detect
|
|
199
|
+
n_anomalies = 0
|
|
200
|
+
for node in nodes:
|
|
201
|
+
for record in node.telemetry_log.get(node.agent.agent_id):
|
|
202
|
+
if node.detect_anomaly(record) is not None:
|
|
203
|
+
n_anomalies += 1
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
"scenario": scenario_name,
|
|
207
|
+
"n_nodes": self.config.n_nodes,
|
|
208
|
+
"n_anomalies": n_anomalies,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
def run_experiment(self, scenario_name: str) -> dict:
|
|
212
|
+
results = [
|
|
213
|
+
self._run_once(scenario_name, (self.config.seed or 0) + i)
|
|
214
|
+
for i in range(self.config.n_runs)
|
|
215
|
+
]
|
|
216
|
+
n_anomalies = [r["n_anomalies"] for r in results]
|
|
217
|
+
return {
|
|
218
|
+
"scenario": scenario_name,
|
|
219
|
+
"n_runs": self.config.n_runs,
|
|
220
|
+
"n_nodes": self.config.n_nodes,
|
|
221
|
+
"mean_anomalies": statistics.mean(n_anomalies),
|
|
222
|
+
"median_anomalies": statistics.median(n_anomalies),
|
|
223
|
+
"stdev_anomalies": (
|
|
224
|
+
statistics.stdev(n_anomalies) if len(n_anomalies) > 1 else 0.0
|
|
225
|
+
),
|
|
226
|
+
"raw_results": results,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def run_all_scenarios(self) -> dict[str, dict]:
|
|
230
|
+
return {s: self.run_experiment(s) for s in SCENARIOS}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
234
|
+
# Experiment helpers
|
|
235
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def experiment_1_detection_accuracy(config: TestbedConfig) -> dict:
|
|
239
|
+
testbed = Testbed(config)
|
|
240
|
+
return {
|
|
241
|
+
"experiment": "detection_accuracy",
|
|
242
|
+
"metrics": ["M1", "M2"],
|
|
243
|
+
"scenarios": testbed.run_all_scenarios(),
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def experiment_2_halt_window(config: TestbedConfig) -> dict:
|
|
248
|
+
testbed = Testbed(config)
|
|
249
|
+
return {
|
|
250
|
+
"experiment": "halt_window",
|
|
251
|
+
"metrics": ["M5"],
|
|
252
|
+
"results": testbed.run_experiment("S5"),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def experiment_3_recovery_correctness(config: TestbedConfig) -> dict:
|
|
257
|
+
testbed = Testbed(config)
|
|
258
|
+
return {
|
|
259
|
+
"experiment": "recovery_correctness",
|
|
260
|
+
"metrics": ["M6"],
|
|
261
|
+
"results": testbed.run_experiment("S1"),
|
|
262
|
+
}
|