#!/usr/bin/env python3 """Run the real ScadaBridge consumer in separate processes against both application versions.""" import argparse import hashlib import json import os from pathlib import Path import secrets import subprocess import time def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--baseline-host", type=Path, required=True) parser.add_argument("--candidate-host", type=Path, required=True) parser.add_argument("--probe", type=Path, required=True) parser.add_argument("--results", type=Path, required=True) args = parser.parse_args() root = args.results.resolve() root.mkdir(parents=True, exist_ok=False) artifacts = {name: path.resolve() for name, path in vars(args).items() if name != "results"} for path in artifacts.values(): if not path.is_file(): raise FileNotFoundError(path) receipt = { "artifacts": {name: {"path": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} for name, path in artifacts.items()}, "legs": [], } # ScadaBridge#121: secure legs provision the consumer identity first, then exchange PUBLIC certs # both ways. Each secure mode also runs two negative controls that must fail closed: # untrusted-client - the server's trusted store does not hold the consumer certificate # untrusted-server - the consumer's trusted store does not hold the server certificate legs = [(m, "trusted") for m in ("None", "Sign", "SignAndEncrypt")] legs += [(m, n) for m in ("Sign", "SignAndEncrypt") for n in ("untrusted-client", "untrusted-server")] for version in ("baseline", "candidate"): for mode, trust in legs: leg = root / (f"{version}-{mode}" if trust == "trusted" else f"{version}-{mode}-{trust}") leg.mkdir() server_root = leg / "server" env = os.environ.copy() env["OPCUA_MATRIX_PASSWORD"] = "otua_matrix_" + secrets.token_urlsafe(32) env.pop("SCADABRIDGE_PROBE_PKI", None) env.pop("SCADABRIDGE_PROBE_SERVER_CERTS", None) if mode == "None": env["OPCUA_MATRIX_SERVER_SECURITY"] = "none" elif mode == "Sign": env["OPCUA_MATRIX_SERVER_SECURITY"] = "sign" else: env.pop("OPCUA_MATRIX_SERVER_SECURITY", None) server_command = ["dotnet", str(artifacts[f"{version}_host"]), "server", str(server_root)] result = {"server": version, "mode": mode, "trust": trust, "expect": "pass" if trust == "trusted" else "fail", "serverCommand": server_command} if mode != "None": pki = leg / "consumer-pki" der = leg / "consumer-identity.der" with (leg / "prepare.log").open("w") as log: prepared = subprocess.run(["dotnet", str(artifacts["probe"]), "prepare", str(pki), str(der)], env=env, stdout=log, stderr=subprocess.STDOUT, timeout=120) if prepared.returncode != 0: raise RuntimeError(f"Consumer identity provisioning failed; inspect {leg / 'prepare.log'}") env["SCADABRIDGE_PROBE_PKI"] = str(pki) if trust != "untrusted-client": trusted = server_root / "server-pki" / "trusted" / "certs" trusted.mkdir(parents=True) (trusted / "scadabridge-consumer.der").write_bytes(der.read_bytes()) result["consumerIdentitySha256"] = hashlib.sha256(der.read_bytes()).hexdigest() with (leg / "server.log").open("w") as server_log: server = subprocess.Popen(server_command, env=env, stdout=server_log, stderr=subprocess.STDOUT) try: until = time.monotonic() + 40 while not (server_root / "endpoint.txt").is_file(): if server.poll() is not None or time.monotonic() >= until: raise RuntimeError(f"Server failed to become ready; inspect {leg / 'server.log'}") time.sleep(0.1) endpoint = (server_root / "endpoint.txt").read_text().strip() if trust != "untrusted-server": env["SCADABRIDGE_PROBE_SERVER_CERTS"] = str(server_root / "server-pki" / "own" / "certs") command = ["dotnet", str(artifacts["probe"]), endpoint, mode, str(leg / "consumer")] with (leg / "consumer.log").open("w") as log: probe = subprocess.run(command, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=150) result.update(command=command, exit=probe.returncode, result=str(leg / "consumer" / "result.json")) finally: server_root.mkdir(exist_ok=True) (server_root / "stop").touch() try: server.wait(timeout=15) except subprocess.TimeoutExpired: server.terminate() server.wait(timeout=10) result["serverExit"] = server.returncode receipt["legs"].append(result) (root / "receipt.json").write_text(json.dumps(receipt, indent=2)) # A trusted leg passes only on exit 0; a negative control passes only when the consumer fails closed. for leg in receipt["legs"]: leg["verdict"] = "passed" if leg["serverExit"] == 0 and (leg["exit"] == 0) == (leg["expect"] == "pass") else "failed" (root / "receipt.json").write_text(json.dumps(receipt, indent=2)) return int(any(leg["verdict"] != "passed" for leg in receipt["legs"])) if __name__ == "__main__": raise SystemExit(main())