""" AUTH.3E — job_schema.py ======================= Geschlossene Job-Schema-Validierung für den Executor Command Channel. Eigenschaften: * job_type als geschlossene Allowlist (Enum): C5_SAVE_OBJECT | C5_DELETE_OBJECT * Typisierte Felder (uuid, sha256-hex, ISO8601-UTC, enum) * KEIN generischer Dispatcher: keine command=/endpoint=/url=/method=/shell=/ python=/handler=-Felder erlaubt * Unknown job_type -> REJECTED * Extra privileged fields -> REJECTED * Malformed schema -> REJECTED * Command-Injection-Defense: Jobs dürfen niemals shell/executable/python/url/ headers/credentials/sql/docker/ssh enthalten Isoliert implementiert (KEIN produktiver Container). Wiederverwendet die AUTH.3D-Payload-Normalisierung (approval_payload._normalize_path) für Pfade. """ from __future__ import annotations import re import uuid from typing import Any, Dict, List, Optional # --------------------------------------------------------------------------- # Geschlossene Job-Type-Allowlist # --------------------------------------------------------------------------- JOB_TYPE_SAVE = "C5_SAVE_OBJECT" JOB_TYPE_DELETE = "C5_DELETE_OBJECT" JOB_TYPES = frozenset({JOB_TYPE_SAVE, JOB_TYPE_DELETE}) JOB_VERSION = 1 # --------------------------------------------------------------------------- # Verbotene privilegierte Felder (Command-Injection-Defense) # --------------------------------------------------------------------------- # Diese Felder dürfen in KEINEM Job vorkommen. Ihr Vorhandensein -> REJECTED. FORBIDDEN_FIELDS = frozenset({ "command", "endpoint", "url", "method", "shell", "python", "handler", "exec", "executable", "script", "headers", "authorization", "credential", "token", "password", "secret", "sql", "query", "docker", "ssh", "base_url", "http_method", "auth_header", }) # --------------------------------------------------------------------------- # Erlaubte Felder je Job-Type (geschlossene Schemata) # --------------------------------------------------------------------------- BASE_FIELDS = frozenset({ "job_version", "job_id", "mission_id", "job_type", "object_id", "vault_path", "created_at", "idempotency_key", }) SAVE_FIELDS = BASE_FIELDS | frozenset({ "source_commit", "provenance_hash", "expected_state", }) DELETE_FIELDS = BASE_FIELDS | frozenset({ "delete_request_id", "expected_commit", "expected_provenance_hash", "approval_id", }) ALLOWED_FIELDS = { JOB_TYPE_SAVE: SAVE_FIELDS, JOB_TYPE_DELETE: DELETE_FIELDS, } # --------------------------------------------------------------------------- # Validierungs-Helfer # --------------------------------------------------------------------------- _UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") _SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") _SHA1_RE = re.compile(r"^[0-9a-fA-F]{40}$") _ISO8601_UTC_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") def _is_uuid(value: Any) -> bool: return isinstance(value, str) and bool(_UUID_RE.match(value)) def _is_sha256(value: Any) -> bool: return isinstance(value, str) and bool(_SHA256_RE.match(value)) def _is_sha1(value: Any) -> bool: return isinstance(value, str) and bool(_SHA1_RE.match(value)) def _is_iso8601_utc(value: Any) -> bool: return isinstance(value, str) and bool(_ISO8601_UTC_RE.match(value)) def _is_nonempty_str(value: Any) -> bool: return isinstance(value, str) and len(value) > 0 def _normalize_path(path: str) -> str: """ Normalisiert einen Vault-Pfad (keine //, ., .., trailing slash). Wiederverwendet die AUTH.3D-Pfad-Normalisierung. """ try: from approval_payload import _normalize_path as _ap_normalize return _ap_normalize(path) except Exception: # Fallback (isoliert): eigene Normalisierung parts = [p for p in path.split("/") if p not in ("", ".")] if ".." in parts: raise ValueError("path traversal") return "/" + "/".join(parts) def _validate_path(path: str) -> Optional[str]: """ Pfad-Sicherheitsprüfung (Executor-seitig, Defense in depth). Erlaubt nur Pfade unterhalb des Vault-Roots, keine Traversal, keine absoluten Host-Pfade, keine Unicode-Ambiguität, keine URL-Encodierung, keine Sonderzeichen. """ if not isinstance(path, str) or not path: return "path must be a non-empty string" if len(path) > 1024: return "path too long" if "\x00" in path: return "path contains null byte" # Kein absoluter Host-Pfad (nur /app/vault/... erlaubt) if not path.startswith("/app/vault/"): return "path must be under /app/vault/" # Zeichensatz-Whitelist: nur sichere Pfadzeichen. # Schließt URL-Encodierung (%), Unicode-Homoglyphen, Leerzeichen, # Steuerzeichen und Sonderzeichen aus. if not re.fullmatch(r"[A-Za-z0-9/._-]+", path): return "path contains disallowed characters" # Traversal / Normalisierung try: norm = _normalize_path(path) except ValueError: return "path traversal detected" if norm != path: return "path not normalized" # Unicode-Ambiguität: keine Homoglyphen-/Normalisierungs-Angriffe import unicodedata if unicodedata.normalize("NFC", path) != path: return "path not NFC-normalized" return None # --------------------------------------------------------------------------- # Job-Schema-Validierung # --------------------------------------------------------------------------- class JobSchemaError(ValueError): """Basis-Fehler für Schema-Validierung.""" class JobRejectedError(JobSchemaError): """Job wurde REJECTED (Schema/Allowlist/Injection-Fehler).""" def validate_job(job: Dict[str, Any]) -> Dict[str, Any]: """ Validiert einen Job gegen das geschlossene Schema. Rückgabe: normalisierter Job (bei Erfolg). Wirft JobRejectedError bei: * unknown job_type * malformed schema * extra privileged fields * Command-Injection-Felder * Pfad-Verletzungen """ if not isinstance(job, dict): raise JobRejectedError("job must be a dict") # 1. job_type muss in der geschlossenen Allowlist sein job_type = job.get("job_type") if not isinstance(job_type, str) or job_type not in JOB_TYPES: raise JobRejectedError(f"unknown job_type: {job_type!r}") # 2. job_version muss stimmen if job.get("job_version") != JOB_VERSION: raise JobRejectedError(f"unknown job_version: {job.get('job_version')!r}") # 3. Keine privilegierten/verbotenen Felder extra = set(job.keys()) - ALLOWED_FIELDS[job_type] if extra: raise JobRejectedError(f"extra/unknown fields: {sorted(extra)}") forbidden = set(job.keys()) & FORBIDDEN_FIELDS if forbidden: raise JobRejectedError(f"forbidden privileged fields: {sorted(forbidden)}") # 4. Pflichtfelder vorhanden required = ALLOWED_FIELDS[job_type] missing = required - set(job.keys()) if missing: raise JobRejectedError(f"missing required fields: {sorted(missing)}") # 5. Typ-Validierung if not _is_uuid(job.get("job_id")): raise JobRejectedError("job_id must be a UUID") if not _is_uuid(job.get("mission_id")): raise JobRejectedError("mission_id must be a UUID") if not _is_uuid(job.get("idempotency_key")): raise JobRejectedError("idempotency_key must be a UUID") if not _is_nonempty_str(job.get("object_id")): raise JobRejectedError("object_id must be a non-empty string") if len(job.get("object_id", "")) > 256: raise JobRejectedError("object_id too long") if not _is_iso8601_utc(job.get("created_at")): raise JobRejectedError("created_at must be ISO8601 UTC (YYYY-MM-DDTHH:MM:SSZ)") # 6. Pfad-Sicherheit path_err = _validate_path(job.get("vault_path")) if path_err: raise JobRejectedError(f"vault_path invalid: {path_err}") # 7. Job-Type-spezifische Felder if job_type == JOB_TYPE_SAVE: if not _is_sha1(job.get("source_commit")): raise JobRejectedError("source_commit must be a sha1 hex (git commit)") if not _is_sha256(job.get("provenance_hash")): raise JobRejectedError("provenance_hash must be a sha256 hex") if not _is_nonempty_str(job.get("expected_state")): raise JobRejectedError("expected_state must be a non-empty string") elif job_type == JOB_TYPE_DELETE: if not _is_uuid(job.get("delete_request_id")): raise JobRejectedError("delete_request_id must be a UUID") if not _is_sha1(job.get("expected_commit")): raise JobRejectedError("expected_commit must be a sha1 hex (git commit)") if not _is_sha256(job.get("expected_provenance_hash")): raise JobRejectedError("expected_provenance_hash must be a sha256 hex") if not _is_uuid(job.get("approval_id")): raise JobRejectedError("approval_id must be a UUID") # 8. Keine Duplicate-Keys (JSON-Duplikat-Angriff) # (wird durch parse_payload in approval_payload abgedeckt; hier defensiv) return dict(job) def make_save_job( job_id: str, mission_id: str, object_id: str, vault_path: str, source_commit: str, provenance_hash: str, expected_state: str, created_at: str, idempotency_key: str, ) -> Dict[str, Any]: """Erzeugt einen kanonischen SAVE-Job (validiert).""" job = { "job_version": JOB_VERSION, "job_id": job_id, "mission_id": mission_id, "job_type": JOB_TYPE_SAVE, "object_id": object_id, "vault_path": vault_path, "source_commit": source_commit, "provenance_hash": provenance_hash, "expected_state": expected_state, "created_at": created_at, "idempotency_key": idempotency_key, } return validate_job(job) def make_delete_job( job_id: str, mission_id: str, delete_request_id: str, object_id: str, vault_path: str, expected_commit: str, expected_provenance_hash: str, approval_id: str, created_at: str, idempotency_key: str, ) -> Dict[str, Any]: """Erzeugt einen kanonischen DELETE-Job (validiert).""" job = { "job_version": JOB_VERSION, "job_id": job_id, "mission_id": mission_id, "delete_request_id": delete_request_id, "job_type": JOB_TYPE_DELETE, "object_id": object_id, "vault_path": vault_path, "expected_commit": expected_commit, "expected_provenance_hash": expected_provenance_hash, "approval_id": approval_id, "created_at": created_at, "idempotency_key": idempotency_key, } return validate_job(job)