CIS490/tools/load_mimic.py
Maximus Gorog 970698af83 Synthetic envelope demo: phase-driven load mimic + plotter
End-to-end pipeline now produces a labeled envelope from a single command.
Drives the orchestrator through an 8-phase XMRig-shaped schedule and
renders a 3-panel envelope (CPU%, RSS, IO write rate) with phase bands
sourced from labels.jsonl. Real telemetry, simulated load — validates the
collection + labeling shape before a real VM is involved.

Components:
- tools/load_mimic.py        phase-driven load generator. Reads phase
                             commands on stdin; CPU/IO behavior matches
                             the named phase (clean=idle, armed=light burst,
                             infecting=disk burst+CPU, infected_running=
                             CPU saturation+stratum-shaped writes,
                             dormant=quieter than clean).
- tools/run_envelope_demo.py spawns load_mimic, drives EpisodeRunner with
                             a default 85s schedule that includes the
                             classic infected_running → dormant → re-entry
                             pattern.
- tools/plot_envelope.py     reads telemetry + labels from an episode dir,
                             writes envelope.png with colored phase bands.

orchestrator: EpisodeRunner now takes an optional phase_schedule and an
on_phase callback. Walks the schedule emitting one label per transition.
Backwards-compatible — existing single-phase tests still green.

Doc fix (user pushback): README + architecture + threat-model no longer
imply the Pi5 is the deployment target. Pi5's actual role here is the
WireGuard-side collector for episode tarballs. Deployment target is
generic ("constrained Linux device"). The "gateway observer" concept
remains a deployment pattern, decoupled from the Pi5's collector role.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:53:20 -06:00

126 lines
4.3 KiB
Python

"""Phase-driven load mimic — XMRig-shaped envelope without real malware.
Reads phase commands on stdin (one per line). Adjusts the work loop's
behavior so the host /proc telemetry of THIS process produces a recognizable
envelope across phases:
clean — idle: long sleeps, ~0% CPU
armed — short CPU bursts (key exchange shape)
infecting — CPU burst + transient disk writes (sample drop)
infected_running — sustained CPU burn + periodic small writes
dormant — quieter than clean: long sleeps simulating beacon loss
Used by ``tools/run_envelope_demo.py`` to validate the orchestrator and
collector pipeline against a known phase schedule, before a real VM is
involved. Real telemetry, simulated load.
"""
from __future__ import annotations
import os
import sys
import tempfile
import threading
import time
VALID_PHASES = {"clean", "armed", "infecting", "infected_running", "dormant"}
def _consume_cpu_ms(ms: float) -> None:
end = time.monotonic() + ms / 1000.0
x = 0
while time.monotonic() < end:
# A few thousand ops; the budget is enforced by the deadline.
for _ in range(1000):
x = (x * x + 1) % 1_000_000_007
class LoadMimic:
def __init__(self, scratch_path: str) -> None:
self._phase = "clean"
self._stop = threading.Event()
self._scratch = scratch_path
self._stratum_counter = 0
self._last_stratum = time.monotonic()
self._thread = threading.Thread(target=self._loop, daemon=True, name="load")
self._thread.start()
def set_phase(self, p: str) -> None:
self._phase = p
def stop(self) -> None:
self._stop.set()
self._thread.join(timeout=2.0)
def _loop(self) -> None:
while not self._stop.is_set():
phase = self._phase
if phase == "clean":
# ~1% CPU: short tick, mostly sleeping.
_consume_cpu_ms(1)
time.sleep(0.1)
elif phase == "dormant":
# Quieter than clean; longer sleeps emulate beacon loss.
time.sleep(0.25)
elif phase == "armed":
# Light CPU + a tiny write (TLS-handshake-ish shape).
_consume_cpu_ms(15)
with open(self._scratch, "ab") as f:
f.write(b"armed:" + os.urandom(64) + b"\n")
time.sleep(0.05)
elif phase == "infecting":
# Disk burst (sample landing) + medium CPU.
with open(self._scratch, "ab") as f:
f.write(os.urandom(8192))
_consume_cpu_ms(40)
time.sleep(0.02)
elif phase == "infected_running":
# Heavy CPU; near saturation on one core.
_consume_cpu_ms(95)
# Periodic stratum-like small write every ~3s.
now = time.monotonic()
if now - self._last_stratum > 3.0:
with open(self._scratch, "ab") as f:
f.write(b"stratum:" + os.urandom(96) + b"\n")
self._last_stratum = now
self._stratum_counter += 1
time.sleep(0.005)
else:
# Unknown phase — be safe, idle.
time.sleep(0.1)
def main() -> int:
# Use a per-pid scratch file under /tmp so concurrent runs don't collide.
scratch = os.path.join(tempfile.gettempdir(), f"cis490-load-{os.getpid()}.bin")
open(scratch, "wb").close()
load = LoadMimic(scratch_path=scratch)
sys.stdout.write(f"load_mimic pid={os.getpid()} scratch={scratch}\n")
sys.stdout.flush()
try:
for line in sys.stdin:
cmd = line.strip()
if cmd in VALID_PHASES:
load.set_phase(cmd)
sys.stdout.write(f"phase={cmd}\n")
sys.stdout.flush()
elif cmd in ("quit", "exit"):
break
elif cmd == "":
continue
else:
sys.stdout.write(f"unknown: {cmd}\n")
sys.stdout.flush()
finally:
load.stop()
try:
os.unlink(scratch)
except FileNotFoundError:
pass
return 0
if __name__ == "__main__":
sys.exit(main())