353 lines
16 KiB
Python
353 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
CP2A1 — Trusted Gate Evaluator Unit-Tests (A-O) + Bypass-Tests (17).
|
|
|
|
Verwendet Fixture-State (isolierte Test-Verzeichnisse), NICHT den produktiven
|
|
Control-State. Kein produktiver State wird veraendert.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
import gate_evaluator as ge # noqa: E402
|
|
|
|
|
|
def _write_state(state_dir, name, content):
|
|
os.makedirs(state_dir, exist_ok=True)
|
|
with open(os.path.join(state_dir, name), "w") as f:
|
|
f.write(content)
|
|
|
|
|
|
def _make_fixture(global_autonomy="OFF", emergency="OFF", boot_id="boot-1234"):
|
|
"""Baut ein isoliertes Fixture-State-Verzeichnis. Rueckgabe: (state_dir, boot_id)."""
|
|
tmp = tempfile.mkdtemp(prefix="cp2a1_fixture_")
|
|
_write_state(tmp, "global_autonomy", global_autonomy)
|
|
_write_state(tmp, "productive_mutations", "OFF")
|
|
_write_state(tmp, "save_execution", "OFF")
|
|
_write_state(tmp, "delete_execution", "OFF")
|
|
_write_state(tmp, "emergency_stop", emergency)
|
|
return tmp, boot_id
|
|
|
|
|
|
class GateEvaluatorTest(unittest.TestCase):
|
|
def setUp(self):
|
|
# Fixture-State isoliert setzen (NICHT produktiv)
|
|
self._orig_state_dir = ge.AUTHORITATIVE_STATE_DIR
|
|
self._orig_boot_file = ge.AUTHORITATIVE_BOOT_ID_FILE
|
|
self._orig_audit_dir = ge.AUDIT_DIR
|
|
self._fixture_dir, self._boot_id = _make_fixture()
|
|
ge.AUTHORITATIVE_STATE_DIR = self._fixture_dir
|
|
ge.AUTHORITATIVE_BOOT_ID_FILE = os.path.join(self._fixture_dir, "boot_id")
|
|
ge.AUDIT_DIR = os.path.join(self._fixture_dir, "audit")
|
|
# control_reader auf Fixture zeigen lassen
|
|
ge.control_reader.STATE_DIR = self._fixture_dir
|
|
ge.control_reader.BOOT_ID_FILE = ge.AUTHORITATIVE_BOOT_ID_FILE
|
|
# boot_id-Datei schreiben
|
|
with open(ge.AUTHORITATIVE_BOOT_ID_FILE, "w") as f:
|
|
f.write(self._boot_id)
|
|
|
|
def tearDown(self):
|
|
ge.AUTHORITATIVE_STATE_DIR = self._orig_state_dir
|
|
ge.AUTHORITATIVE_BOOT_ID_FILE = self._orig_boot_file
|
|
ge.AUDIT_DIR = self._orig_audit_dir
|
|
ge.control_reader.STATE_DIR = self._orig_state_dir
|
|
ge.control_reader.BOOT_ID_FILE = self._orig_boot_file
|
|
shutil.rmtree(self._fixture_dir, ignore_errors=True)
|
|
|
|
# --- A: global ON + emergency OFF + valid boot -> ALLOW ---
|
|
def test_A_global_on_emergency_off_valid_boot_allow(self):
|
|
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
|
|
_write_state(self._fixture_dir, "emergency_stop", "OFF")
|
|
d = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d["result"], "ALLOW")
|
|
self.assertEqual(d["reason_code"], ge.RC_ALLOW)
|
|
|
|
# --- B: global OFF -> DENY ---
|
|
def test_B_global_off_deny(self):
|
|
_write_state(self._fixture_dir, "global_autonomy", "OFF")
|
|
_write_state(self._fixture_dir, "emergency_stop", "OFF")
|
|
d = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_GLOBAL_AUTONOMY_OFF)
|
|
|
|
# --- C: emergency ON -> DENY ---
|
|
def test_C_emergency_on_deny(self):
|
|
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
|
|
_write_state(self._fixture_dir, "emergency_stop", "ON")
|
|
d = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_EMERGENCY_ON)
|
|
|
|
# --- D: grant boot mismatch -> DENY ---
|
|
def test_D_grant_boot_mismatch_deny(self):
|
|
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=other-boot\n")
|
|
_write_state(self._fixture_dir, "emergency_stop", "OFF")
|
|
d = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_GLOBAL_AUTONOMY_OFF)
|
|
|
|
# --- E: missing global -> DENY ---
|
|
def test_E_missing_global_deny(self):
|
|
os.remove(os.path.join(self._fixture_dir, "global_autonomy"))
|
|
_write_state(self._fixture_dir, "emergency_stop", "OFF")
|
|
d = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_GLOBAL_AUTONOMY_OFF)
|
|
|
|
# --- F: malformed global -> DENY ---
|
|
def test_F_malformed_global_deny(self):
|
|
_write_state(self._fixture_dir, "global_autonomy", "MAYBE")
|
|
_write_state(self._fixture_dir, "emergency_stop", "OFF")
|
|
d = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_GLOBAL_AUTONOMY_OFF)
|
|
|
|
# --- G: missing emergency -> DENY (fail-closed: unklar = ON) ---
|
|
def test_G_missing_emergency_deny(self):
|
|
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
|
|
os.remove(os.path.join(self._fixture_dir, "emergency_stop"))
|
|
d = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_EMERGENCY_ON)
|
|
|
|
# --- H: malformed emergency -> DENY ---
|
|
def test_H_malformed_emergency_deny(self):
|
|
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
|
|
_write_state(self._fixture_dir, "emergency_stop", "MAYBE")
|
|
d = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_EMERGENCY_ON)
|
|
|
|
# --- I: missing boot_id -> DENY ---
|
|
def test_I_missing_boot_id_deny(self):
|
|
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
|
|
_write_state(self._fixture_dir, "emergency_stop", "OFF")
|
|
os.remove(ge.AUTHORITATIVE_BOOT_ID_FILE)
|
|
d = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_BOOT_ID_ERROR)
|
|
|
|
# --- J: malformed boot_id -> DENY ---
|
|
def test_J_malformed_boot_id_deny(self):
|
|
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
|
|
_write_state(self._fixture_dir, "emergency_stop", "OFF")
|
|
with open(ge.AUTHORITATIVE_BOOT_ID_FILE, "w") as f:
|
|
f.write("has whitespace\n")
|
|
d = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_BOOT_ID_ERROR)
|
|
|
|
# --- K: unknown action -> DENY ---
|
|
def test_K_unknown_action_deny(self):
|
|
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
|
|
_write_state(self._fixture_dir, "emergency_stop", "OFF")
|
|
for cls in ("SAVE", "DELETE", "FORGEJO_WRITE", "NOTION_WRITE", "TELEGRAM_SEND",
|
|
"HOST_MUTATION", "EXTERNAL_MUTATION", "BOGUS"):
|
|
d = ge._check_action(cls)
|
|
self.assertEqual(d["result"], "DENY", f"action {cls} should DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_UNKNOWN_ACTION_CLASS)
|
|
|
|
# --- L: malformed request -> DENY ---
|
|
def test_L_malformed_request_deny(self):
|
|
cases = [
|
|
b"",
|
|
b"not json",
|
|
b"[]",
|
|
b'"string"',
|
|
b"null",
|
|
b"{}",
|
|
b'{"action_class": 123}',
|
|
b'{"action_class": ""}',
|
|
b'{"action_class": "AUTONOMOUS_TICK_START", "extra": 1}',
|
|
]
|
|
for body in cases:
|
|
cls, err = ge._validate_check_request(body)
|
|
self.assertIsNone(cls, f"body {body!r} should be invalid")
|
|
self.assertEqual(err, ge.RC_INVALID_REQUEST)
|
|
|
|
# --- M: evaluator exception -> DENY ---
|
|
def test_M_evaluator_exception_deny(self):
|
|
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
|
|
_write_state(self._fixture_dir, "emergency_stop", "OFF")
|
|
# Simuliere Exception in read_control_state -> _effective_state -> None -> DENY
|
|
orig = ge.control_reader.read_control_state
|
|
ge.control_reader.read_control_state = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
|
|
try:
|
|
d = ge._check_action("AUTONOMOUS_TICK_START")
|
|
finally:
|
|
ge.control_reader.read_control_state = orig
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_INTERNAL_ERROR)
|
|
|
|
# --- N: cache/stale-state cannot authorize ---
|
|
def test_N_no_cache_stale_state(self):
|
|
# Erst ALLOW, dann State auf OFF aendern -> naechste Entscheidung muss DENY sein
|
|
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
|
|
_write_state(self._fixture_dir, "emergency_stop", "OFF")
|
|
d1 = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d1["result"], "ALLOW")
|
|
# State aendern (kein Cache)
|
|
_write_state(self._fixture_dir, "global_autonomy", "OFF")
|
|
d2 = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d2["result"], "DENY")
|
|
self.assertEqual(d2["reason_code"], ge.RC_GLOBAL_AUTONOMY_OFF)
|
|
|
|
# --- O: RQ-supplied path ignored/rejected ---
|
|
def test_O_rq_supplied_path_ignored(self):
|
|
# Der Evaluator akzeptiert KEINEN State-Pfad im Request.
|
|
# /check-Request mit "state_path" Feld -> INVALID_REQUEST
|
|
cls, err = ge._validate_check_request(
|
|
b'{"action_class": "AUTONOMOUS_TICK_START", "state_path": "/etc/passwd"}'
|
|
)
|
|
self.assertIsNone(cls)
|
|
self.assertEqual(err, ge.RC_INVALID_REQUEST)
|
|
|
|
|
|
class BypassTest(unittest.TestCase):
|
|
"""Fresh isolated checker: versucht Bypass-Pfade. Erwartung: kein Bypass."""
|
|
|
|
def setUp(self):
|
|
self._orig_state_dir = ge.AUTHORITATIVE_STATE_DIR
|
|
self._orig_boot_file = ge.AUTHORITATIVE_BOOT_ID_FILE
|
|
self._orig_audit_dir = ge.AUDIT_DIR
|
|
self._fixture_dir, self._boot_id = _make_fixture()
|
|
ge.AUTHORITATIVE_STATE_DIR = self._fixture_dir
|
|
ge.AUTHORITATIVE_BOOT_ID_FILE = os.path.join(self._fixture_dir, "boot_id")
|
|
ge.AUDIT_DIR = os.path.join(self._fixture_dir, "audit")
|
|
ge.control_reader.STATE_DIR = self._fixture_dir
|
|
ge.control_reader.BOOT_ID_FILE = ge.AUTHORITATIVE_BOOT_ID_FILE
|
|
with open(ge.AUTHORITATIVE_BOOT_ID_FILE, "w") as f:
|
|
f.write(self._boot_id)
|
|
|
|
def tearDown(self):
|
|
ge.AUTHORITATIVE_STATE_DIR = self._orig_state_dir
|
|
ge.AUTHORITATIVE_BOOT_ID_FILE = self._orig_boot_file
|
|
ge.AUDIT_DIR = self._orig_audit_dir
|
|
ge.control_reader.STATE_DIR = self._orig_state_dir
|
|
ge.control_reader.BOOT_ID_FILE = self._orig_boot_file
|
|
shutil.rmtree(self._fixture_dir, ignore_errors=True)
|
|
|
|
def _allow_setup(self):
|
|
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
|
|
_write_state(self._fixture_dir, "emergency_stop", "OFF")
|
|
|
|
def test_alternate_state_path(self):
|
|
# Request mit state_path -> INVALID_REQUEST (kein Bypass)
|
|
cls, err = ge._validate_check_request(
|
|
b'{"action_class": "AUTONOMOUS_TICK_START", "state_path": "/tmp/evil"}'
|
|
)
|
|
self.assertIsNone(cls)
|
|
self.assertEqual(err, ge.RC_INVALID_REQUEST)
|
|
|
|
def test_path_traversal(self):
|
|
# Path-Traversal-Versuch in action_class -> nicht in Whitelist -> DENY
|
|
cls, err = ge._validate_check_request(
|
|
b'{"action_class": "../../etc/passwd"}'
|
|
)
|
|
self.assertEqual(cls, "../../etc/passwd")
|
|
d = ge._check_action(cls)
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_UNKNOWN_ACTION_CLASS)
|
|
|
|
def test_env_override(self):
|
|
# ENV kann die hart verdrahteten Pfade NICHT ueberschreiben.
|
|
# Die Default-Konstanten (vor Fixture-Ueberschreibung) sind die autoritativen Pfade.
|
|
self.assertEqual(ge._DEFAULT_STATE_DIR, "/opt/control-plane/state")
|
|
self.assertEqual(ge._DEFAULT_BOOT_ID_FILE, "/proc/sys/kernel/random/boot_id")
|
|
# ENV-Variablen werden im Evaluator nicht gelesen (kein os.environ.get fuer Pfade)
|
|
self.assertNotIn("C5_CONTROL_STATE_DIR", os.environ)
|
|
self.assertNotIn("C5_BOOT_ID_FILE", os.environ)
|
|
|
|
def test_action_injection(self):
|
|
# Action Class in anderem Feld -> INVALID_REQUEST
|
|
cls, err = ge._validate_check_request(
|
|
b'{"action_class": "AUTONOMOUS_TICK_START", "inject": "SAVE"}'
|
|
)
|
|
self.assertIsNone(cls)
|
|
self.assertEqual(err, ge.RC_INVALID_REQUEST)
|
|
|
|
def test_oversized_request(self):
|
|
big = b'{"action_class": "' + b"A" * 5000 + b'"}'
|
|
cls, err = ge._validate_check_request(big)
|
|
self.assertIsNone(cls)
|
|
self.assertEqual(err, ge.RC_INVALID_REQUEST)
|
|
|
|
def test_duplicate_fields(self):
|
|
# JSON mit doppeltem action_class: json.loads loest still auf (letzter Wert).
|
|
# Der Wert wird trotzdem gegen die Whitelist validiert -> kein Bypass.
|
|
cls, err = ge._validate_check_request(
|
|
b'{"action_class": "AUTONOMOUS_TICK_START", "action_class": "AUTONOMOUS_TICK_START"}'
|
|
)
|
|
self.assertEqual(cls, "AUTONOMOUS_TICK_START")
|
|
# Duplikat mit bösartigem Wert -> DENY (Whitelist-Check greift)
|
|
cls2, _ = ge._validate_check_request(
|
|
b'{"action_class": "AUTONOMOUS_TICK_START", "action_class": "SAVE"}'
|
|
)
|
|
self.assertEqual(cls2, "SAVE")
|
|
d = ge._check_action(cls2)
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_UNKNOWN_ACTION_CLASS)
|
|
|
|
def test_null_values(self):
|
|
cls, err = ge._validate_check_request(b'{"action_class": null}')
|
|
self.assertIsNone(cls)
|
|
self.assertEqual(err, ge.RC_INVALID_REQUEST)
|
|
|
|
def test_unicode_confusable_action(self):
|
|
# Unicode-Konfusables sind nicht in der Whitelist -> DENY
|
|
cls, err = ge._validate_check_request(b'{"action_class": "AUTONOMOUS_TICK_START\\u200b"}')
|
|
# Zero-Width-Space -> nicht in Whitelist -> UNKNOWN_ACTION_CLASS
|
|
self.assertEqual(cls, "AUTONOMOUS_TICK_START\u200b")
|
|
d = ge._check_action(cls)
|
|
self.assertEqual(d["result"], "DENY")
|
|
self.assertEqual(d["reason_code"], ge.RC_UNKNOWN_ACTION_CLASS)
|
|
|
|
def test_unknown_json_keys(self):
|
|
cls, err = ge._validate_check_request(b'{"foo": "bar"}')
|
|
self.assertIsNone(cls)
|
|
self.assertEqual(err, ge.RC_INVALID_REQUEST)
|
|
|
|
def test_malformed_json(self):
|
|
cls, err = ge._validate_check_request(b'{"action_class": "AUTONOMOUS_TICK_START"')
|
|
self.assertIsNone(cls)
|
|
self.assertEqual(err, ge.RC_INVALID_REQUEST)
|
|
|
|
def test_replay_same_request(self):
|
|
# Replay desselben Requests: Entscheidung wird frisch berechnet (kein Cache).
|
|
# Bei State=OFF -> DENY, auch wenn vorher ALLOW war.
|
|
self._allow_setup()
|
|
d1 = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d1["result"], "ALLOW")
|
|
_write_state(self._fixture_dir, "global_autonomy", "OFF")
|
|
d2 = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d2["result"], "DENY")
|
|
|
|
def test_evaluator_restart(self):
|
|
# Simuliere Restart: State bleibt, Entscheidung frisch
|
|
self._allow_setup()
|
|
d1 = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d1["result"], "ALLOW")
|
|
# "Restart" = neue Instanz (frischer Aufruf)
|
|
d2 = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d2["result"], "ALLOW")
|
|
|
|
def test_host_boot_id_fixture_change(self):
|
|
# boot_id aendern -> Grant boot mismatch -> DENY
|
|
self._allow_setup()
|
|
d1 = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d1["result"], "ALLOW")
|
|
with open(ge.AUTHORITATIVE_BOOT_ID_FILE, "w") as f:
|
|
f.write("new-boot-9999")
|
|
d2 = ge._check_action("AUTONOMOUS_TICK_START")
|
|
self.assertEqual(d2["result"], "DENY")
|
|
self.assertEqual(d2["reason_code"], ge.RC_GLOBAL_AUTONOMY_OFF)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|