2026-07-20 13:53:07 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Plot cumulative compromise curves produced by freeroutesim."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import csv
|
|
|
|
|
import sys
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import matplotlib
|
|
|
|
|
|
|
|
|
|
if "--show" not in sys.argv:
|
|
|
|
|
matplotlib.use("Agg")
|
|
|
|
|
import matplotlib.pyplot as plt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class Curve:
|
|
|
|
|
x: list[float]
|
|
|
|
|
probability: list[float]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class SimulationResult:
|
|
|
|
|
label: str
|
|
|
|
|
time: Curve
|
|
|
|
|
messages: Curve
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
description=(
|
|
|
|
|
"Plot cumulative probability of first compromise against virtual "
|
|
|
|
|
"time and number of messages."
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"csv_files",
|
|
|
|
|
nargs="+",
|
|
|
|
|
type=Path,
|
|
|
|
|
help="One or more CSV files written by freeroutesim --csv",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"-o",
|
|
|
|
|
"--output",
|
|
|
|
|
type=Path,
|
|
|
|
|
help="Output image path (default: <first-csv-stem>_plot.png)",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--labels",
|
|
|
|
|
nargs="+",
|
|
|
|
|
help="Legend labels in the same order as the CSV files",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument("--title", help="Optional figure title")
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--log-messages",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Use a logarithmic x-axis for the message-count plot",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--show",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Open the plot after saving it",
|
|
|
|
|
)
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
if args.labels is not None and len(args.labels) != len(args.csv_files):
|
|
|
|
|
parser.error("--labels must contain exactly one label per CSV file")
|
|
|
|
|
return args
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_result(path: Path, label: str) -> SimulationResult:
|
|
|
|
|
time_points: list[tuple[float, float]] = []
|
|
|
|
|
message_points: list[tuple[float, float]] = []
|
|
|
|
|
|
|
|
|
|
with path.open(newline="", encoding="utf-8") as csv_file:
|
|
|
|
|
reader = csv.DictReader(csv_file)
|
|
|
|
|
required = {"curve", "x", "day", "cumulative_probability"}
|
|
|
|
|
missing = required.difference(reader.fieldnames or [])
|
|
|
|
|
if missing:
|
|
|
|
|
missing_columns = ", ".join(sorted(missing))
|
|
|
|
|
raise ValueError(f"{path}: missing CSV columns: {missing_columns}")
|
|
|
|
|
|
|
|
|
|
for line_number, row in enumerate(reader, start=2):
|
|
|
|
|
try:
|
|
|
|
|
probability = float(row["cumulative_probability"])
|
|
|
|
|
if row["curve"] == "time_seconds":
|
|
|
|
|
time_points.append((float(row["day"]), probability))
|
|
|
|
|
elif row["curve"] == "message_count":
|
|
|
|
|
message_points.append((float(row["x"]), probability))
|
|
|
|
|
except (TypeError, ValueError) as error:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"{path}:{line_number}: invalid numeric value"
|
|
|
|
|
) from error
|
|
|
|
|
|
|
|
|
|
if not time_points:
|
|
|
|
|
raise ValueError(f"{path}: contains no time_seconds curve")
|
|
|
|
|
if not message_points:
|
|
|
|
|
raise ValueError(f"{path}: contains no message_count curve")
|
|
|
|
|
|
|
|
|
|
time_points.sort()
|
|
|
|
|
message_points.sort()
|
|
|
|
|
return SimulationResult(
|
|
|
|
|
label=label,
|
|
|
|
|
time=Curve(
|
|
|
|
|
x=[point[0] for point in time_points],
|
|
|
|
|
probability=[point[1] for point in time_points],
|
|
|
|
|
),
|
|
|
|
|
messages=Curve(
|
|
|
|
|
x=[point[0] for point in message_points],
|
|
|
|
|
probability=[point[1] for point in message_points],
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def plot_results(
|
|
|
|
|
results: list[SimulationResult],
|
|
|
|
|
output: Path,
|
|
|
|
|
title: str | None,
|
|
|
|
|
log_messages: bool,
|
|
|
|
|
show: bool,
|
|
|
|
|
) -> None:
|
|
|
|
|
figure, (time_axis, message_axis) = plt.subplots(1, 2, figsize=(13, 5.5))
|
|
|
|
|
|
2026-07-21 13:36:23 +02:00
|
|
|
colors = plt.get_cmap("tab20").colors if len(results) > 10 else [None] * len(results)
|
|
|
|
|
for result, color in zip(results, colors):
|
2026-07-20 13:53:07 +02:00
|
|
|
time_axis.step(
|
|
|
|
|
result.time.x,
|
|
|
|
|
result.time.probability,
|
|
|
|
|
where="post",
|
|
|
|
|
label=result.label,
|
2026-07-21 13:36:23 +02:00
|
|
|
color=color,
|
2026-07-20 13:53:07 +02:00
|
|
|
)
|
|
|
|
|
message_axis.step(
|
|
|
|
|
result.messages.x,
|
|
|
|
|
result.messages.probability,
|
|
|
|
|
where="post",
|
|
|
|
|
label=result.label,
|
2026-07-21 13:36:23 +02:00
|
|
|
color=color,
|
2026-07-20 13:53:07 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
configure_axis(
|
|
|
|
|
time_axis,
|
|
|
|
|
xlabel="time (days)",
|
|
|
|
|
title="Time to first compromise",
|
|
|
|
|
)
|
|
|
|
|
configure_axis(
|
|
|
|
|
message_axis,
|
|
|
|
|
xlabel="Messages sent",
|
|
|
|
|
title="Messages to first compromise",
|
|
|
|
|
)
|
|
|
|
|
if log_messages:
|
|
|
|
|
message_axis.set_xscale("symlog", linthresh=1)
|
2026-07-21 13:36:23 +02:00
|
|
|
message_axis.set_xlim(left=0)
|
|
|
|
|
|
|
|
|
|
if len(results) > 6:
|
|
|
|
|
handles, labels = time_axis.get_legend_handles_labels()
|
|
|
|
|
figure.legend(
|
|
|
|
|
handles,
|
|
|
|
|
labels,
|
|
|
|
|
loc="lower center",
|
|
|
|
|
ncol=6,
|
|
|
|
|
bbox_to_anchor=(0.5, -0.01),
|
|
|
|
|
)
|
|
|
|
|
elif len(results) > 1:
|
2026-07-20 13:53:07 +02:00
|
|
|
time_axis.legend()
|
|
|
|
|
message_axis.legend()
|
|
|
|
|
if title:
|
|
|
|
|
figure.suptitle(title)
|
|
|
|
|
|
2026-07-21 13:36:23 +02:00
|
|
|
figure.tight_layout(rect=(0, 0.09, 1, 1) if len(results) > 6 else None)
|
2026-07-20 13:53:07 +02:00
|
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
figure.savefig(output, dpi=200, bbox_inches="tight")
|
|
|
|
|
print(f"wrote {output}")
|
|
|
|
|
|
|
|
|
|
if show:
|
|
|
|
|
plt.show()
|
|
|
|
|
else:
|
|
|
|
|
plt.close(figure)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def configure_axis(axis: plt.Axes, xlabel: str, title: str) -> None:
|
|
|
|
|
axis.set_title(title)
|
|
|
|
|
axis.set_xlabel(xlabel)
|
|
|
|
|
axis.set_ylabel("Cumulative probability of compromise")
|
|
|
|
|
axis.set_ylim(0.0, 1.01)
|
|
|
|
|
axis.grid(True, alpha=0.3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
args = parse_args()
|
|
|
|
|
labels = args.labels or [path.stem for path in args.csv_files]
|
|
|
|
|
results = [
|
|
|
|
|
read_result(path, label) for path, label in zip(args.csv_files, labels)
|
|
|
|
|
]
|
|
|
|
|
output = args.output or args.csv_files[0].with_name(
|
|
|
|
|
f"{args.csv_files[0].stem}_plot.png"
|
|
|
|
|
)
|
|
|
|
|
plot_results(results, output, args.title, args.log_messages, args.show)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|