- adds safe_executor.py: REPO_ROOT confinement, strict script allowlist, env key allowlist (STRICT/SOFIIA_URL/BFF_A/BFF_B/NODE_ID/AGENT_ID), stdin=DEVNULL, 8KB output cap, timeout clamp (max 300s), non-root warn - integrates script action_type into runbook_runner: next_step handles http_check and script branches; running_as_root -> step_status=warn - extends runbook_parser: rehearsal-v1 now includes 3 built-in script steps (preflight, idempotency smoke, generate evidence) after http_checks - adds tests/test_sofiia_safe_executor.py: 12 tests covering path traversal, absolute path, non-allowlist, env drop, timeout, exit_code, mocked subprocess Made-with: Cursor
209 lines
6.6 KiB
Python
209 lines
6.6 KiB
Python
"""
|
|
SafeExecutor — PR3.
|
|
|
|
Execute allowlisted shell scripts via asyncio.create_subprocess_exec (not shell=True).
|
|
Security: path confinement to REPO_ROOT (realpath), strict env allowlist,
|
|
stdin=DEVNULL, output cap, timeout clamp, non-root warning.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── Config ────────────────────────────────────────────────────────────────────
|
|
|
|
def _get_repo_root() -> Path:
|
|
"""SOFIIA_REPO_ROOT env or auto-detect: app/ -> sofiia-console/ -> services/ -> repo."""
|
|
env = os.getenv("SOFIIA_REPO_ROOT", "").strip()
|
|
if env:
|
|
return Path(env).resolve()
|
|
return Path(__file__).resolve().parent.parent.parent.parent
|
|
|
|
|
|
_REPO_ROOT = _get_repo_root()
|
|
|
|
# Allowlisted scripts (relative to REPO_ROOT)
|
|
_SCRIPT_ALLOWLIST: frozenset = frozenset({
|
|
"ops/preflight_sofiia_console.sh",
|
|
"ops/redis_idempotency_smoke.sh",
|
|
"ops/generate_release_evidence.sh",
|
|
})
|
|
|
|
# Env keys allowed to be passed from action_json
|
|
_ENV_KEY_ALLOWLIST: frozenset = frozenset({
|
|
"STRICT",
|
|
"SOFIIA_URL",
|
|
"BFF_A",
|
|
"BFF_B",
|
|
"NODE_ID",
|
|
"AGENT_ID",
|
|
})
|
|
|
|
_TIMEOUT_MAX_S: int = 300
|
|
_OUTPUT_CAP_BYTES: int = 8 * 1024 # 8 KB
|
|
|
|
|
|
# ── Public API ────────────────────────────────────────────────────────────────
|
|
|
|
class ScriptNotAllowedError(ValueError):
|
|
pass
|
|
|
|
|
|
def _validate_script_path(script: str) -> Path:
|
|
"""
|
|
Validate that script is in the allowlist and resides under REPO_ROOT.
|
|
Returns resolved absolute Path.
|
|
Raises ScriptNotAllowedError on any violation.
|
|
"""
|
|
if not script or not script.strip():
|
|
raise ScriptNotAllowedError("Empty script path")
|
|
|
|
# Reject absolute paths and traversal immediately (before resolve)
|
|
s = script.strip()
|
|
if s.startswith("/"):
|
|
raise ScriptNotAllowedError(f"Absolute paths not allowed: {s!r}")
|
|
if ".." in Path(s).parts:
|
|
raise ScriptNotAllowedError(f"Path traversal not allowed: {s!r}")
|
|
|
|
# Exact allowlist check (on normalized relative form)
|
|
normalized = s.replace("\\", "/")
|
|
if normalized not in _SCRIPT_ALLOWLIST:
|
|
raise ScriptNotAllowedError(f"Script not in allowlist: {normalized!r}")
|
|
|
|
resolved = (_REPO_ROOT / normalized).resolve()
|
|
# Confinement: must be under REPO_ROOT
|
|
try:
|
|
resolved.relative_to(_REPO_ROOT)
|
|
except ValueError:
|
|
raise ScriptNotAllowedError(f"Script escaped REPO_ROOT: {resolved}")
|
|
|
|
return resolved
|
|
|
|
|
|
def _filter_env(raw_env: Optional[Dict[str, Any]]) -> tuple[Dict[str, str], List[str]]:
|
|
"""Return (filtered_env, list_of_dropped_keys)."""
|
|
if not raw_env:
|
|
return {}, []
|
|
filtered: Dict[str, str] = {}
|
|
dropped: List[str] = []
|
|
for k, v in raw_env.items():
|
|
if k in _ENV_KEY_ALLOWLIST:
|
|
filtered[k] = str(v)
|
|
else:
|
|
dropped.append(k)
|
|
return filtered, dropped
|
|
|
|
|
|
def _cap_bytes(data: bytes, cap: int = _OUTPUT_CAP_BYTES) -> str:
|
|
if not data:
|
|
return ""
|
|
text = data.decode("utf-8", errors="replace")
|
|
if len(data) > cap:
|
|
tail = text[-(cap // 2):]
|
|
return f"[...truncated...]\n{tail}"
|
|
return text
|
|
|
|
|
|
async def run_script(
|
|
script: str,
|
|
env: Optional[Dict[str, Any]] = None,
|
|
timeout_s: int = 120,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Execute allowlisted script. Returns:
|
|
{ok, exit_code, stdout_tail, stderr_tail, duration_ms, timeout, warning?}
|
|
"""
|
|
try:
|
|
resolved = _validate_script_path(script)
|
|
except ScriptNotAllowedError as e:
|
|
return {"ok": False, "exit_code": None, "error": str(e),
|
|
"stdout_tail": "", "stderr_tail": "", "duration_ms": 0, "timeout": False}
|
|
|
|
# Build env: inherit minimal set, add allowed overrides
|
|
base_env: Dict[str, str] = {
|
|
k: v for k, v in os.environ.items()
|
|
if k in {"PATH", "HOME", "LANG", "LC_ALL", "USER", "LOGNAME", "SOFIIA_DATA_DIR", "SOFIIA_REDIS_URL"}
|
|
}
|
|
filtered_env, dropped_keys = _filter_env(env)
|
|
if dropped_keys:
|
|
logger.warning("safe_executor: dropped non-allowlisted env keys: %s", dropped_keys)
|
|
base_env.update(filtered_env)
|
|
|
|
# Clamp timeout
|
|
effective_timeout = max(1, min(int(timeout_s), _TIMEOUT_MAX_S))
|
|
|
|
# Non-root check
|
|
warning: Optional[str] = None
|
|
try:
|
|
if os.getuid() == 0:
|
|
warning = "running_as_root"
|
|
logger.warning("safe_executor: running as root, step status will be warn")
|
|
except AttributeError:
|
|
pass # Windows / no getuid
|
|
|
|
started = time.monotonic()
|
|
timed_out = False
|
|
exit_code: Optional[int] = None
|
|
stdout_bytes = b""
|
|
stderr_bytes = b""
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"/bin/bash",
|
|
str(resolved),
|
|
cwd=str(_REPO_ROOT),
|
|
env=base_env,
|
|
stdin=asyncio.subprocess.DEVNULL,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
proc.communicate(), timeout=float(effective_timeout)
|
|
)
|
|
exit_code = proc.returncode
|
|
except asyncio.TimeoutError:
|
|
timed_out = True
|
|
try:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
except Exception:
|
|
pass
|
|
exit_code = None
|
|
except Exception as e:
|
|
logger.error("safe_executor: failed to start %s: %s", script, e)
|
|
duration_ms = int((time.monotonic() - started) * 1000)
|
|
return {
|
|
"ok": False,
|
|
"exit_code": None,
|
|
"error": str(e)[:200],
|
|
"stdout_tail": "",
|
|
"stderr_tail": "",
|
|
"duration_ms": duration_ms,
|
|
"timeout": False,
|
|
}
|
|
|
|
duration_ms = int((time.monotonic() - started) * 1000)
|
|
ok = (not timed_out) and exit_code == 0
|
|
result: Dict[str, Any] = {
|
|
"ok": ok,
|
|
"exit_code": exit_code,
|
|
"stdout_tail": _cap_bytes(stdout_bytes),
|
|
"stderr_tail": _cap_bytes(stderr_bytes),
|
|
"duration_ms": duration_ms,
|
|
"timeout": timed_out,
|
|
"dropped_env_keys": dropped_keys or None,
|
|
}
|
|
if warning:
|
|
result["warning"] = warning
|
|
return result
|