Wraps the three remaining 🚧 items from the README so every collector the threat-model promises is actually live, and the Tier-4 path (real-malware fetch + upload + exec) works end-to-end as soon as a sha256 lands in samples/store/. Closes spectral/CIS490#4, #5, #6. == #6 — Bridge pcap wiring == EpisodeConfig grows three optional fields: bridge_iface: str | None # e.g. "br-malware" bridge_ip: str = "10.200.0.1" pcap_snaplen: int = 256 When bridge_iface is set, EpisodeRunner spawns tcpdump for the duration of the schedule (network.pcap), stops it cleanly on episode end, and runs collectors.pcap.bucketize() to produce netflow.jsonl per the 100-ms schema in docs/data-model.md. EpisodeResult + meta.result gain rows_netflow + pcap_bytes counters. vm/launch_demo.sh + launch_target.sh now switch between SLIRP usermode and tap+bridge based on $BRIDGE — operator pre-creates the tap as a bridge member, no sudo from the launcher. run_real_vm_demo.py picks BRIDGE up from env so the fleet runner can opt entire waves into pcap mode by exporting BRIDGE before invocation. == #5 — Source 3 perf collector == collectors/perf_qemu.py shells out to ``perf stat -p <pid> -I 100 -j`` and parses the per-event JSON stream. Aggregates one row per interval across the canonical event set (cycles/instructions/cache-{refs,misses}/ branches/branch-misses/page-faults/context-switches), computes IPC + cache-miss rate. Tolerates missing events (``<not counted>`` / ``<not supported>``) without dropping the row, and skips cleanly when ``perf`` isn't on PATH or the process can't be attached. EpisodeConfig.enable_perf=True opts into the collector — off by default because perf needs CAP_SYS_ADMIN or perf_event_paranoid <= 1. When enabled, runs as a parallel thread alongside the other collectors; EpisodeResult.rows_perf records the count. == #4 — Tier 4 (real-malware fetch + upload + exec) == tools/fetch_sample.py: pulls a sample by sha256 from MalwareBazaar (API key from env or samples/.bazaar.token), unzips with the standard "infected" password, verifies the resulting binary's sha256, lands at samples/store/<sha256>. Idempotent — already-staged correct binaries return immediately. samples/manifest.py: Sample.binary_path(store_root) resolves to the staged binary path, or None for mimics / not-yet-fetched real samples. exploits/workloads.py: real_binary_workload(bytes, sample) builds a Workload that base64-uploads the binary into the shell session via a heredoc, decodes + chmods + execs it in the background, captures the PID for clean stop on dormant. Per-profile pid/bin paths so concurrent samples in the same guest don't collide. exploits/driver.py: dispatch order is now: 1) sample.kind == "real" + binary staged at sample_store_root → real_binary_workload (Tier 4) 2) profile mimic from workloads.workload_for() (Tier 3 v2) 3) None → driver v1 fallback yes-loop DriverConfig.sample_store_root is the new field; run_tier3_demo.py wires it to repo_root/samples/store. driver_setup event records sample_sha256 so trainers can join Tier-4 episodes against the manifest by hash. samples/store/.gitkeep added (binaries themselves are gitignored). Tests: 102 pass (was 86). New suites: tests/test_perf_qemu.py — parser + builder + perf-missing fallback tests/test_tier4.py — real_binary_workload base64 round-trip, stop-cmd kills pidfile, per-profile path isolation, driver dispatch chooses real vs mimic correctly, fetcher input validation and cached-fast-path Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
82 lines
2.9 KiB
Python
82 lines
2.9 KiB
Python
"""Tests for the perf-stat collector — parser logic in isolation
|
|
(no actual perf invocation, since perf needs CAP_SYS_ADMIN and
|
|
hardware counters that the test runner can't assume)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from collectors import perf_qemu
|
|
|
|
|
|
def test_parse_event_line_extracts_fields() -> None:
|
|
line = '{"interval":0.100123,"counter-value":"1234567","unit":"","event":"cycles"}'
|
|
evt = perf_qemu.parse_perf_event_line(line)
|
|
assert evt is not None
|
|
assert evt["event"] == "cycles"
|
|
assert evt["interval"] == 0.100123
|
|
assert evt["counter-value"] == "1234567"
|
|
|
|
|
|
def test_parse_event_line_skips_non_json() -> None:
|
|
assert perf_qemu.parse_perf_event_line("") is None
|
|
assert perf_qemu.parse_perf_event_line("garbage") is None
|
|
assert perf_qemu.parse_perf_event_line("# Performance counter stats") is None
|
|
|
|
|
|
def test_coerce_int_handles_perf_quirks() -> None:
|
|
assert perf_qemu._coerce_int("1234567") == 1234567
|
|
assert perf_qemu._coerce_int("1,234,567") == 1234567
|
|
assert perf_qemu._coerce_int("<not counted>") is None
|
|
assert perf_qemu._coerce_int("<not supported>") is None
|
|
assert perf_qemu._coerce_int("") is None
|
|
assert perf_qemu._coerce_int(None) is None
|
|
assert perf_qemu._coerce_int(42) == 42
|
|
|
|
|
|
def test_build_row_computes_ipc_and_miss_rate() -> None:
|
|
agg = {
|
|
"cycles": 1_000_000_000,
|
|
"instructions": 660_000_000,
|
|
"cache-references": 1_000_000,
|
|
"cache-misses": 50_000,
|
|
"branches": 100_000_000,
|
|
"branch-misses": 5_000_000,
|
|
"page-faults": 12,
|
|
"context-switches": 20,
|
|
}
|
|
row = perf_qemu._build_row(t_mono_origin_ns=0, interval_s=0.1, agg=agg)
|
|
assert row["source"] == "host_perf"
|
|
assert row["available_in_deployment"] is False
|
|
assert row["cycles"] == 1_000_000_000
|
|
assert row["instructions"] == 660_000_000
|
|
assert pytest.approx(row["ipc"], abs=1e-9) == 0.66
|
|
assert pytest.approx(row["cache_miss_rate"], abs=1e-9) == 0.05
|
|
assert row["interval_s"] == 0.1
|
|
|
|
|
|
def test_build_row_handles_missing_counters() -> None:
|
|
"""If perf can't enable cache-misses on this hardware, the row
|
|
should still be valid — just with None for the missing fields."""
|
|
agg = {"cycles": 100, "instructions": 50}
|
|
row = perf_qemu._build_row(t_mono_origin_ns=0, interval_s=0.1, agg=agg)
|
|
assert row["cycles"] == 100
|
|
assert row["cache_misses"] is None
|
|
assert row["cache_miss_rate"] is None
|
|
assert pytest.approx(row["ipc"], abs=1e-9) == 0.5
|
|
|
|
|
|
def test_run_loop_returns_zero_when_perf_missing(tmp_path: Path, monkeypatch) -> None:
|
|
monkeypatch.setattr(perf_qemu, "perf_available", lambda: False)
|
|
import threading
|
|
rows = perf_qemu.run_loop(
|
|
pid=1,
|
|
output_path=tmp_path / "telemetry-perf.jsonl",
|
|
t_mono_origin_ns=0,
|
|
interval_ms=100,
|
|
stop_event=threading.Event(),
|
|
)
|
|
assert rows == 0
|