trading-system-docs/tolaria/c5-sync-service/job_schema.py

304 lines
11 KiB
Python

"""
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).
KANONISCH RELATIV (AUTH.4C2 PATH CONTRACT REPAIR, OPTION A):
kein führender Slash. Immer die eigene relative Normalisierung —
KEIN Import von approval_payload._normalize_path, da jene (AUTH.3D)
absolute Pfade erzwingt und damit die kanonisch-relative Semantik
verletzen würde (keine Dual-Semantik).
"""
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).
KANONISCHER JOB-VAULT-PFAD = RELATIVER Pfad unter dem Vault-Root
(AUTH.4C2 PATH CONTRACT REPAIR, OPTION A).
Erlaubt NUR relative Pfade. Lehnt ab:
* absolute Pfade (führende /) — Tolaria AUTH.2 lehnt absolute ab
* Traversal (.., ./)
* Backslash-Traversal
* URL/Scheme (http://, file://, C:)
* Null-Bytes, Unicode-Ambiguität, URL-Encodierung, 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"
# Kanonisch RELATIV: keine führende /, kein Scheme, kein Backslash
if path.startswith("/"):
return "path must be relative (no leading /)"
if "\\" in path:
return "path contains backslash"
if ":" in path:
return "path contains scheme/colon"
# 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)