304 lines
11 KiB
Python
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)
|