Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **SCEPTRE App**: `configure()` and `pre_start()` are stage classes, `ConfigureStage` and `PreStart`, sharing pre-start state through `PreStartState`.
- **SCEPTRE App**: The device-type table is `configs/infrastructures.yaml`; adding a device type needs no code change.
- **SCEPTRE App**: Injections are declared with `Sceptre.inject()`, and all of them in the configure stage.
- **Common**: `utils.abs_path()` always returns a `Path`.
- **SCEPTRE App**: Type annotations on every app function, `Final` on module constants.
- **SCEPTRE App**: Validation failures raise `error.AppError` instead of calling `sys.exit(1)`, matching the app contract.
- **SCEPTRE App**: `metadata.simulator` matches case-insensitively in both stages, as validation already did; a miscased name used to silently get the default config.
Expand All @@ -36,6 +37,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **SCEPTRE App**: A `fep` without a mgmt interface raised `UnboundLocalError`, or reused the previous fep's endpoints.
- **SCEPTRE App**: A historian on a subnet with no OPC server was configured with an unrelated OPC's tag list and no address to collect from. It now gets no tags and a warning naming the subnet.

### Security

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup the slop

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed.

- **Common**: New `validate_hostname()` (phenix v2026.10.02 rules) and `safe_join()` guard hostnames and paths built from metadata; `mm_send()`/`mm_recv()` stay inside the miniccc mount. **Breaking:** rejects `_`, 1-char, all-digit, `all` and `phenix` hostnames.
- **Apps**: Scale, wind turbine and Ignition names are restricted; protonuke and wireguard reject embedded newlines; wireguard configs are `0600`, sceptre startup scripts `0755`.
- **SCORCH**: `cc`, `ssh` and `providerdata` transfers stay inside the run directory.

## [2.0.0] - 2026-03-04

### Changed
Expand Down
7 changes: 5 additions & 2 deletions src/python/phenix_apps/apps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import re
import sys
from pathlib import Path
from typing import Any

from box import Box
Expand Down Expand Up @@ -45,7 +46,7 @@ def __init__(self, name: str, stage: str, dryrun: bool = False) -> None:
self.topo = self.get_annotation("topology")

# Create the experiment directory if it doesn't exist
os.makedirs(self.exp_dir, exist_ok=True)
Path(self.exp_dir).mkdir(parents=True, exist_ok=True)

# Mako templates directory inside the app's code folder
py_path = sys.modules[self.__class__.__module__].__file__
Expand Down Expand Up @@ -347,6 +348,8 @@ def extract_node_hostname_for_ip(self, address: str) -> str | None:
return None

def add_node(self, new_node: Box | dict, overwrite: bool = False) -> None:
utils.validate_hostname(new_node["general"]["hostname"])

found = None

for idx, node in enumerate(self.experiment.spec.topology.nodes):
Expand Down Expand Up @@ -435,7 +438,7 @@ def render(self, template_name: str, file_path: str, **kwargs) -> str:
Returns the file path written to.
"""

with open(file_path, "w") as fp:
with Path(file_path).open("w") as fp:
utils.mako_serve_template(
template_name=template_name,
templates_dir=self.templates_dir,
Expand Down
70 changes: 44 additions & 26 deletions src/python/phenix_apps/apps/caldera/app.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,36 @@
import ipaddress as ipaddr
import os
from pathlib import Path

from phenix_apps.apps import AppBase
from phenix_apps.common import settings, utils
from phenix_apps.common.error import AppError
from phenix_apps.common.logger import logger


class Caldera(AppBase):
def __init__(self, name: str, stage: str, dryrun: bool = False) -> None:
super().__init__(name, stage, dryrun)

self.app_dir: str = f"{self.exp_dir}/caldera"
os.makedirs(self.app_dir, exist_ok=True)
self.app_dir: Path = Path(self.exp_dir) / "caldera"
self.app_dir.mkdir(parents=True, exist_ok=True)

self.files_dir: str = f"{settings.PHENIX_DIR}/images/{self.exp_name}/caldera"
os.makedirs(self.files_dir, exist_ok=True)
self.files_dir: Path = (
Path(settings.PHENIX_DIR) / "images" / self.exp_name / "caldera"
)
self.files_dir.mkdir(parents=True, exist_ok=True)

def configure(self):
logger.info(f"Configuring user app: {self.name}")

md = self.metadata

for idx, server in enumerate(md.get("servers", [])):
hostname = utils.validate_hostname(server.get("hostname", f"caldera-{idx}"))

node = {
"type": "VirtualMachine",
"general": {
"hostname": server.get("hostname", f"caldera-{idx}"),
"hostname": hostname,
"vm_type": "kvm",
},
"hardware": {
Expand Down Expand Up @@ -65,69 +70,69 @@ def pre_start(self):
md = self.metadata

for idx, server in enumerate(md.get("servers", [])):
hostname = server.get("hostname", f"caldera-{idx}")
hostname = utils.validate_hostname(server.get("hostname", f"caldera-{idx}"))

node = self.extract_node(hostname)
addr = node.network.interfaces[0].address

for fact in server.get("facts", []):
inject = {
"src": fact,
"dst": f"/opt/caldera/data/sources/{os.path.basename(fact)}",
"dst": f"/opt/caldera/data/sources/{Path(fact).name}",
}

self.add_inject(hostname, inject)

for adversary in server.get("adversaries", []):
inject = {
"src": adversary,
"dst": f"/opt/caldera/data/adversaries/{os.path.basename(adversary)}",
"dst": f"/opt/caldera/data/adversaries/{Path(adversary).name}",
}

self.add_inject(hostname, inject)

if server.get("config"):
config_file = server.get("config")
else:
config_file = f"{self.app_dir}/{hostname}-config.yml"
config_file = utils.safe_join(self.app_dir, f"{hostname}-config.yml")

with open(config_file, "w") as f:
with config_file.open("w") as f:
utils.mako_serve_template("default_config.mako", templates, f)

inject = {
"src": config_file,
"src": str(config_file),
"dst": "/opt/caldera/conf/default.yml",
}

self.add_inject(hostname, inject)

firefox_bookmark_config_file = (
f"{self.app_dir}/{hostname}-firefox-policies.json"
firefox_bookmark_config_file = utils.safe_join(
self.app_dir, f"{hostname}-firefox-policies.json"
)

with open(firefox_bookmark_config_file, "w") as f:
with firefox_bookmark_config_file.open("w") as f:
utils.mako_serve_template(
"firefox_bookmark.mako", templates, f, addr=addr
)

inject = {
"src": firefox_bookmark_config_file,
"src": str(firefox_bookmark_config_file),
"dst": "/etc/firefox/policies/policies.json",
}

self.add_inject(hostname, inject)

firefox_autostart_config_file = (
f"{self.app_dir}/{hostname}-firefox-autostart.json"
firefox_autostart_config_file = utils.safe_join(
self.app_dir, f"{hostname}-firefox-autostart.json"
)

with open(firefox_autostart_config_file, "w") as f:
with firefox_autostart_config_file.open("w") as f:
utils.mako_serve_template(
"firefox_autostart.mako", templates, f, addr=addr
)

inject = {
"src": firefox_autostart_config_file,
"src": str(firefox_autostart_config_file),
"dst": "/root/.config/autostart/Caldera.desktop",
}

Expand All @@ -136,6 +141,8 @@ def pre_start(self):
hosts = self.extract_all_nodes(False)

for host in hosts:
utils.validate_hostname(host.hostname)

try:
addr = ipaddr.ip_address(host.metadata.server)
except ValueError:
Expand All @@ -151,32 +158,43 @@ def pre_start(self):
addr = node.network.interfaces[iface].address

if host.topology.hardware.os_type == "windows":
agent_file = f"{self.app_dir}/{host.hostname}-sandcat-agent.ps1"
if not (Path(templates) / "windows_agent.mako").exists():
raise AppError(
f"cannot generate sandcat agent for Windows host "
f"'{host.hostname}': windows_agent.mako template is missing "
f"from {templates}"
)

with open(agent_file, "w") as f:
agent_file = utils.safe_join(
self.app_dir, f"{host.hostname}-sandcat-agent.ps1"
)

with agent_file.open("w") as f:
utils.mako_serve_template(
"windows_agent.mako", templates, f, addr=addr
)

self.add_inject(
hostname=host.hostname,
inject={
"src": agent_file,
"src": str(agent_file),
"dst": "/phenix/startup/90-sandcat-agent.ps1",
},
)
elif host.topology.hardware.os_type == "linux":
agent_file = f"{self.app_dir}/{host.hostname}-sandcat-agent.sh"
agent_file = utils.safe_join(
self.app_dir, f"{host.hostname}-sandcat-agent.sh"
)

with open(agent_file, "w") as f:
with agent_file.open("w") as f:
utils.mako_serve_template(
"linux_agent.mako", templates, f, addr=addr
)

self.add_inject(
hostname=host.hostname,
inject={
"src": agent_file,
"src": str(agent_file),
"dst": "/etc/phenix/startup/90-sandcat-agent.sh",
},
)
Expand Down
26 changes: 15 additions & 11 deletions src/python/phenix_apps/apps/helics/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import os
from pathlib import Path

from phenix_apps.apps import AppBase
from phenix_apps.common import utils
Expand All @@ -9,8 +9,8 @@ class Helics(AppBase):
def __init__(self, name: str, stage: str, dryrun: bool = False) -> None:
super().__init__(name, stage, dryrun)

self.helics_dir: str = f"{self.exp_dir}/helics"
os.makedirs(self.helics_dir, exist_ok=True)
self.helics_dir: Path = Path(self.exp_dir) / "helics"
self.helics_dir.mkdir(parents=True, exist_ok=True)

def pre_start(self):
logger.info(f"Starting user application: {self.name}")
Expand Down Expand Up @@ -47,8 +47,8 @@ def pre_start(self):

# create the wait script to be injected into federates
templates = utils.abs_path(__file__, "templates/")
wait_file = f"{self.helics_dir}/wait-broker.sh"
with open(wait_file, "w") as f:
wait_file = self.helics_dir / "wait-broker.sh"
with wait_file.open("w") as f:
utils.mako_serve_template(
"wait_broker.mako", templates, f, rootbroker_ip=root_ip
)
Expand All @@ -72,7 +72,8 @@ def pre_start(self):
if configs and configs[0].get("broker-wait", True):
dst = "/etc/phenix/startup/5-wait-broker.sh"
self.add_inject(
hostname=fed.general.hostname, inject={"src": wait_file, "dst": dst}
hostname=fed.general.hostname,
inject={"src": str(wait_file), "dst": dst},
)

for config in configs:
Expand Down Expand Up @@ -129,7 +130,7 @@ def pre_start(self):
"feds": total_fed_count,
"endpoint": root_ip,
"log-level": broker_md.get("log-level", "summary"),
"log-file": os.path.join(log_dir, "helics-root-broker.log"),
"log-file": str(Path(log_dir) / "helics-root-broker.log"),
}

# per-host broker configs, initialized with root broker
Expand All @@ -153,19 +154,22 @@ def pre_start(self):
"parent": root_ip,
"endpoint": endpoint,
"log-level": level,
"log-file": os.path.join(log_dir, "helics-sub-broker.log"),
"log-file": str(Path(log_dir) / "helics-sub-broker.log"),
}
)

configs[hostname] = broker_configs

for hostname, broker_configs in configs.items():
start_file = f"{self.helics_dir}/{hostname}-broker.sh"
utils.validate_hostname(hostname)
start_file = utils.safe_join(self.helics_dir, f"{hostname}-broker.sh")

with open(start_file, "w") as f:
with start_file.open("w") as f:
utils.mako_serve_template(
"broker.mako", templates, f, configs=broker_configs
)

dst = "/etc/phenix/startup/90-helics-broker.sh"
self.add_inject(hostname=hostname, inject={"src": start_file, "dst": dst})
self.add_inject(
hostname=hostname, inject={"src": str(start_file), "dst": dst}
)
2 changes: 1 addition & 1 deletion src/python/phenix_apps/apps/ignition/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ spec:
| Option | Default | Description |
|-----------------------|------------------|------------------------------------------------------|
| `hostname` | (required) | Topology hostname of the outstation. |
| `name` | hostname | Ignition device name; tags reference it (e.g. `[custom-name]AnalogInput0`). |
| `name` | hostname | Ignition device name; tags reference it (e.g. `[custom-name]AnalogInput0`). Letters, digits, spaces, `_` and `-`, starting with a letter or digit, 2 to 63 characters, not `all` or `phenix`. |
| `port` | `20000` | Outstation TCP port. |
| `source_address` | `1` | DNP3 master address (ot-sim default). |
| `destination_address` | `1024` | DNP3 outstation address (ot-sim default). |
Expand Down
15 changes: 14 additions & 1 deletion src/python/phenix_apps/apps/ignition/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ class RtuDeviceConfig(BaseModel):

model_config = {"extra": "ignore"}

@field_validator("name")
@classmethod
def _validate_device_name(cls, v: str | None) -> str | None:
if v is not None and (
not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_ -]{1,62}", v)
or v.lower() in utils.RESERVED_HOSTNAMES
):
raise ValueError(
"device names must be 2 to 63 letters, digits, spaces, '_' and "
"'-', start with a letter or digit, and not be 'all' or 'phenix'"
)
return v

@property
def resolved_name(self) -> str:
return self.name or self.hostname
Expand Down Expand Up @@ -359,7 +372,7 @@ def _write_device_tree(
injects = []

for device in devices:
device_dir = Path(host_dir, "devices", device["name"])
device_dir = utils.safe_join(host_dir, "devices", device["name"])
device_dir.mkdir(parents=True, exist_ok=True)

with Path(device_dir, "config.json").open("w") as f:
Expand Down
6 changes: 6 additions & 0 deletions src/python/phenix_apps/apps/ignition/tests/test_ignition.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ def test_rtu_name_override_wins():
)


@pytest.mark.parametrize("name", ["x", "all", "Phenix", "../etc"])
def test_rtu_name_rejects_short_reserved_and_unsafe(name):
with pytest.raises(ValidationError, match="device names"):
RtuDeviceConfig(hostname="rtu-1", name=name)


def test_host_config_defaults():
cfg = IgnitionHostConfig()
assert cfg.gwbk is None
Expand Down
Loading
Loading