新增了包括密钥加解密、配置健康检查、原子写入、审计、解析器以及 scrubber 在内的完整 secrets 模块,实现了明文密钥检测替换、密钥引用解析和配置安全校验能力
471 lines
17 KiB
Python
471 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from abc import ABC, abstractmethod
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from yuxi.channel.secrets.models import (
|
|
SINGLE_VALUE_FILE_REF_ID,
|
|
SecretRef,
|
|
SecretSource,
|
|
is_record,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_FILE_MAX_BYTES = 1024 * 1024
|
|
DEFAULT_FILE_TIMEOUT_MS = 5_000
|
|
DEFAULT_EXEC_TIMEOUT_MS = 5_000
|
|
DEFAULT_EXEC_MAX_OUTPUT_BYTES = 1024 * 1024
|
|
DEFAULT_EXEC_MAX_BATCH_BYTES = 256 * 1024
|
|
|
|
|
|
class SecretsProvider(ABC):
|
|
"""Abstract base for secret resolution providers."""
|
|
|
|
@abstractmethod
|
|
async def resolve(self, ref: SecretRef) -> str | None: ...
|
|
|
|
@abstractmethod
|
|
async def resolve_batch(self, refs: list[SecretRef]) -> dict[str, str | None]: ...
|
|
|
|
@property
|
|
@abstractmethod
|
|
def source_type(self) -> SecretSource: ...
|
|
|
|
|
|
class EnvProvider(SecretsProvider):
|
|
def __init__(self, allowlist: set[str] | None = None):
|
|
self._allowlist = allowlist
|
|
|
|
@property
|
|
def source_type(self) -> SecretSource:
|
|
return SecretSource.ENV
|
|
|
|
async def resolve(self, ref: SecretRef) -> str | None:
|
|
resolved = await self.resolve_batch([ref])
|
|
return resolved.get(ref.ref)
|
|
|
|
async def resolve_batch(self, refs: list[SecretRef]) -> dict[str, str | None]:
|
|
result: dict[str, str | None] = {}
|
|
for ref in refs:
|
|
if self._allowlist is not None and ref.ref not in self._allowlist:
|
|
logger.warning("EnvProvider: ref '%s' not in allowlist", ref.ref)
|
|
result[ref.ref] = None
|
|
continue
|
|
value = os.getenv(ref.ref)
|
|
if value is None:
|
|
logger.warning("EnvProvider: env var '%s' not set", ref.ref)
|
|
result[ref.ref] = value
|
|
return result
|
|
|
|
|
|
class FileProvider(SecretsProvider):
|
|
def __init__(
|
|
self,
|
|
base_dir: str | Path | None = None,
|
|
mode: str = "json",
|
|
max_bytes: int = DEFAULT_FILE_MAX_BYTES,
|
|
timeout_ms: int = DEFAULT_FILE_TIMEOUT_MS,
|
|
allow_insecure_path: bool = False,
|
|
filename: str = "secrets.json",
|
|
):
|
|
self._base_dir = Path(base_dir) if base_dir else Path.cwd()
|
|
self._mode = mode
|
|
self._max_bytes = max_bytes
|
|
self._timeout_ms = timeout_ms
|
|
self._allow_insecure_path = allow_insecure_path
|
|
self._filename = filename
|
|
self._payload_cache: Any = None
|
|
self._payload_cache_mtime: float | None = None
|
|
|
|
@property
|
|
def source_type(self) -> SecretSource:
|
|
return SecretSource.FILE
|
|
|
|
async def resolve(self, ref: SecretRef) -> str | None:
|
|
resolved = await self.resolve_batch([ref])
|
|
return resolved.get(ref.ref)
|
|
|
|
async def resolve_batch(self, refs: list[SecretRef]) -> dict[str, str | None]:
|
|
payload = await self._read_payload()
|
|
if payload is None:
|
|
return {ref.ref: None for ref in refs}
|
|
|
|
if self._mode == "singleValue":
|
|
result: dict[str, str | None] = {}
|
|
payload_str = payload if isinstance(payload, str) else json.dumps(payload)
|
|
for ref in refs:
|
|
if ref.ref != SINGLE_VALUE_FILE_REF_ID:
|
|
logger.warning(
|
|
"FileProvider: singleValue mode expects ref id '%s', got '%s'",
|
|
SINGLE_VALUE_FILE_REF_ID,
|
|
ref.ref,
|
|
)
|
|
result[ref.ref] = None
|
|
else:
|
|
result[ref.ref] = payload_str
|
|
return result
|
|
|
|
result = {}
|
|
for ref in refs:
|
|
try:
|
|
result[ref.ref] = self._resolve_json_pointer(payload, ref.ref)
|
|
except (KeyError, TypeError) as e:
|
|
logger.warning("FileProvider: JSON pointer '%s' not found: %s", ref.ref, e)
|
|
result[ref.ref] = None
|
|
return result
|
|
|
|
def _resolve_json_pointer(self, payload: Any, pointer: str) -> str:
|
|
if not pointer.startswith("/"):
|
|
raise KeyError(f"JSON pointer must start with '/': {pointer}")
|
|
tokens = pointer[1:].split("/")
|
|
current = payload
|
|
for token in tokens:
|
|
token = token.replace("~1", "/").replace("~0", "~")
|
|
if isinstance(current, dict):
|
|
current = current[token]
|
|
elif isinstance(current, list):
|
|
try:
|
|
idx = int(token)
|
|
except ValueError:
|
|
raise KeyError(f"Invalid array index '{token}' in JSON pointer")
|
|
current = current[idx]
|
|
else:
|
|
raise KeyError(f"Cannot index into non-container at token '{token}'")
|
|
if isinstance(current, str):
|
|
return current
|
|
return json.dumps(current)
|
|
|
|
def _validate_path(self, file_path: Path) -> None:
|
|
if self._allow_insecure_path:
|
|
return
|
|
resolved = file_path.resolve()
|
|
base_resolved = self._base_dir.resolve()
|
|
try:
|
|
resolved.relative_to(base_resolved)
|
|
except ValueError:
|
|
raise PermissionError(
|
|
f"FileProvider: path '{resolved}' is outside allowed base directory '{base_resolved}'"
|
|
)
|
|
|
|
async def _read_payload(self) -> Any:
|
|
file_path = self._base_dir / self._filename
|
|
self._validate_path(file_path)
|
|
|
|
try:
|
|
current_mtime = os.path.getmtime(file_path)
|
|
except OSError:
|
|
current_mtime = None
|
|
|
|
if self._payload_cache_mtime is not None and current_mtime == self._payload_cache_mtime:
|
|
return self._payload_cache
|
|
|
|
try:
|
|
content = await asyncio.wait_for(
|
|
asyncio.to_thread(file_path.read_text, encoding="utf-8"),
|
|
timeout=self._timeout_ms / 1000,
|
|
)
|
|
content = content.strip()
|
|
except FileNotFoundError:
|
|
logger.warning("FileProvider: file not found: %s", file_path)
|
|
self._payload_cache_mtime = current_mtime
|
|
self._payload_cache = None
|
|
return None
|
|
except OSError as e:
|
|
logger.warning("FileProvider: read error for %s: %s", file_path, e)
|
|
self._payload_cache_mtime = current_mtime
|
|
self._payload_cache = None
|
|
return None
|
|
except TimeoutError:
|
|
logger.warning("FileProvider: read timeout for %s", file_path)
|
|
self._payload_cache = None
|
|
return None
|
|
|
|
self._payload_cache_mtime = current_mtime
|
|
try:
|
|
self._payload_cache = json.loads(content)
|
|
except json.JSONDecodeError:
|
|
if self._mode == "singleValue":
|
|
self._payload_cache = content
|
|
else:
|
|
logger.warning("FileProvider: invalid JSON in %s", file_path)
|
|
self._payload_cache = None
|
|
return self._payload_cache
|
|
|
|
def invalidate_cache(self) -> None:
|
|
self._payload_cache_mtime = None
|
|
self._payload_cache = None
|
|
|
|
|
|
class ExecProvider(SecretsProvider):
|
|
EXEC_PROTOCOL_VERSION = 1
|
|
|
|
def __init__(
|
|
self,
|
|
command: str | None = None,
|
|
args: list[str] | None = None,
|
|
trusted_dirs: set[str] | None = None,
|
|
timeout_ms: int = DEFAULT_EXEC_TIMEOUT_MS,
|
|
no_output_timeout_ms: int | None = None,
|
|
max_output_bytes: int = DEFAULT_EXEC_MAX_OUTPUT_BYTES,
|
|
max_batch_bytes: int = DEFAULT_EXEC_MAX_BATCH_BYTES,
|
|
stderr_max_bytes: int = 4096,
|
|
json_only: bool = True,
|
|
pass_env: list[str] | None = None,
|
|
extra_env: dict[str, str] | None = None,
|
|
allow_insecure_path: bool = False,
|
|
allow_symlink_command: bool = False,
|
|
):
|
|
self._command = command
|
|
self._args = args or []
|
|
self._trusted_dirs = trusted_dirs or set()
|
|
self._timeout_ms = timeout_ms
|
|
self._no_output_timeout_ms = no_output_timeout_ms or timeout_ms
|
|
self._max_output_bytes = max_output_bytes
|
|
self._max_batch_bytes = max_batch_bytes
|
|
self._stderr_max_bytes = stderr_max_bytes
|
|
self._json_only = json_only
|
|
self._pass_env = pass_env or []
|
|
self._extra_env = extra_env or {}
|
|
self._allow_insecure_path = allow_insecure_path
|
|
self._allow_symlink_command = allow_symlink_command
|
|
|
|
@property
|
|
def source_type(self) -> SecretSource:
|
|
return SecretSource.EXEC
|
|
|
|
async def resolve(self, ref: SecretRef) -> str | None:
|
|
resolved = await self.resolve_batch([ref])
|
|
return resolved.get(ref.ref)
|
|
|
|
def _validate_command(self, cmd: str) -> str:
|
|
if not cmd:
|
|
raise ValueError("ExecProvider: command is empty")
|
|
|
|
cmd_path = Path(cmd)
|
|
if not self._allow_insecure_path and not cmd_path.is_absolute():
|
|
raise ValueError(f"ExecProvider: command must be an absolute path, got '{cmd}'")
|
|
|
|
resolved_cmd = cmd_path.resolve()
|
|
|
|
if not self._allow_symlink_command:
|
|
real_cmd = resolved_cmd.resolve()
|
|
if real_cmd != resolved_cmd:
|
|
raise ValueError(f"ExecProvider: command '{cmd}' is a symlink and allow_symlink_command is False")
|
|
|
|
if self._trusted_dirs:
|
|
cmd_dir = resolved_cmd.parent
|
|
in_trusted = any(
|
|
cmd_dir == Path(td).resolve() or cmd_dir.is_relative_to(Path(td).resolve()) for td in self._trusted_dirs
|
|
)
|
|
if not in_trusted:
|
|
raise ValueError(f"ExecProvider: command '{resolved_cmd}' is not in trusted directories")
|
|
|
|
return str(resolved_cmd)
|
|
|
|
async def resolve_batch(self, refs: list[SecretRef]) -> dict[str, str | None]:
|
|
ids = list(dict.fromkeys(r.ref for r in refs))
|
|
|
|
if not self._command:
|
|
if not refs:
|
|
return {}
|
|
raise ValueError("ExecProvider: no command configured")
|
|
|
|
cmd = self._command
|
|
cmd = self._validate_command(cmd)
|
|
|
|
request = {
|
|
"protocolVersion": self.EXEC_PROTOCOL_VERSION,
|
|
"provider": "exec",
|
|
"ids": ids,
|
|
}
|
|
input_data = json.dumps(request)
|
|
|
|
if len(input_data.encode("utf-8")) > self._max_batch_bytes:
|
|
logger.warning(
|
|
"ExecProvider: request exceeds max batch bytes (%d > %d)",
|
|
len(input_data.encode("utf-8")),
|
|
self._max_batch_bytes,
|
|
)
|
|
return {ref.ref: None for ref in refs}
|
|
|
|
child_env: dict[str, str] = {}
|
|
for key in self._pass_env:
|
|
value = os.getenv(key)
|
|
if value is not None:
|
|
child_env[key] = value
|
|
child_env.update(self._extra_env)
|
|
|
|
proc = None
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
cmd,
|
|
*self._args,
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=child_env,
|
|
)
|
|
|
|
if proc.stdin is None or proc.stdout is None:
|
|
logger.warning("ExecProvider: failed to open process streams")
|
|
await self._kill_process(proc)
|
|
return {ref.ref: None for ref in refs}
|
|
|
|
proc.stdin.write(input_data.encode())
|
|
await proc.stdin.drain()
|
|
proc.stdin.close()
|
|
await proc.stdin.wait_closed()
|
|
|
|
stdout_bytes, stderr_bytes = await self._read_with_limits(proc)
|
|
except asyncio.CancelledError:
|
|
if proc is not None:
|
|
await self._kill_process(proc)
|
|
raise
|
|
except TimeoutError:
|
|
if proc is not None:
|
|
await self._kill_process(proc)
|
|
logger.warning("ExecProvider: command timed out: %s", cmd[:80])
|
|
return {ref.ref: None for ref in refs}
|
|
except Exception as e:
|
|
if proc is not None:
|
|
await self._kill_process(proc)
|
|
logger.warning("ExecProvider: command failed: %s: %s", cmd[:80], e)
|
|
return {ref.ref: None for ref in refs}
|
|
|
|
if proc.returncode != 0:
|
|
stderr_text = stderr_bytes.decode("utf-8", errors="replace")[:200] if stderr_bytes else ""
|
|
logger.warning(
|
|
"ExecProvider: command exit code %d: %s",
|
|
proc.returncode,
|
|
stderr_text,
|
|
)
|
|
return {ref.ref: None for ref in refs}
|
|
|
|
stdout = stdout_bytes.decode("utf-8").strip() if stdout_bytes else ""
|
|
return self._parse_exec_values(ids, stdout)
|
|
|
|
async def _read_with_limits(self, proc) -> tuple[bytes, bytes]:
|
|
stdout_chunks: list[bytes] = []
|
|
stderr_chunks: list[bytes] = []
|
|
global_deadline = time.monotonic() + self._timeout_ms / 1000
|
|
|
|
async def _read_stream(
|
|
stream: asyncio.StreamReader | None,
|
|
chunks: list[bytes],
|
|
byte_counter: list[int],
|
|
label: str,
|
|
max_bytes: int,
|
|
) -> None:
|
|
if stream is None:
|
|
return
|
|
while True:
|
|
remaining = global_deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError(f"ExecProvider: global timeout ({label})")
|
|
|
|
chunk_timeout = min(remaining, self._no_output_timeout_ms / 1000)
|
|
try:
|
|
chunk = await asyncio.wait_for(stream.read(8192), timeout=chunk_timeout)
|
|
except TimeoutError:
|
|
raise TimeoutError(f"ExecProvider: no output timeout ({label})")
|
|
|
|
if not chunk:
|
|
return
|
|
|
|
byte_counter[0] += len(chunk)
|
|
if byte_counter[0] > max_bytes:
|
|
raise ValueError(
|
|
f"ExecProvider: {label} output exceeded max bytes ({byte_counter[0]} > {max_bytes})"
|
|
)
|
|
|
|
chunks.append(chunk)
|
|
|
|
try:
|
|
stdout_counter = [0]
|
|
stderr_counter = [0]
|
|
stderr_limit = min(self._max_output_bytes, self._stderr_max_bytes)
|
|
await asyncio.gather(
|
|
_read_stream(proc.stdout, stdout_chunks, stdout_counter, "stdout", self._max_output_bytes),
|
|
_read_stream(proc.stderr, stderr_chunks, stderr_counter, "stderr", stderr_limit),
|
|
)
|
|
except Exception:
|
|
await self._kill_process(proc)
|
|
raise
|
|
|
|
await proc.wait()
|
|
return b"".join(stdout_chunks), b"".join(stderr_chunks)
|
|
|
|
async def _kill_process(self, proc) -> None:
|
|
try:
|
|
proc.kill()
|
|
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
except TimeoutError:
|
|
logger.warning("ExecProvider: process did not exit after kill")
|
|
except Exception as e:
|
|
logger.warning("ExecProvider: failed to kill process: %s", e)
|
|
|
|
def _parse_exec_values(self, ids: list[str], stdout: str) -> dict[str, str | None]:
|
|
trimmed = stdout.strip()
|
|
if not trimmed:
|
|
logger.warning("ExecProvider: empty stdout")
|
|
return {ref_id: None for ref_id in ids}
|
|
|
|
if not self._json_only and len(ids) == 1:
|
|
try:
|
|
parsed = json.loads(trimmed)
|
|
except json.JSONDecodeError:
|
|
return {ids[0]: trimmed}
|
|
else:
|
|
try:
|
|
parsed = json.loads(trimmed)
|
|
except json.JSONDecodeError:
|
|
logger.warning("ExecProvider: invalid JSON response")
|
|
return {ref_id: None for ref_id in ids}
|
|
|
|
if not is_record(parsed):
|
|
if not self._json_only and len(ids) == 1 and isinstance(parsed, str):
|
|
return {ids[0]: parsed}
|
|
logger.warning("ExecProvider: response must be an object")
|
|
return {ref_id: None for ref_id in ids}
|
|
|
|
protocol_version = parsed.get("protocolVersion")
|
|
if protocol_version != self.EXEC_PROTOCOL_VERSION:
|
|
logger.warning("ExecProvider: protocolVersion must be %d", self.EXEC_PROTOCOL_VERSION)
|
|
return {ref_id: None for ref_id in ids}
|
|
|
|
response_values = parsed.get("values")
|
|
if not is_record(response_values):
|
|
logger.warning('ExecProvider: response missing "values"')
|
|
return {ref_id: None for ref_id in ids}
|
|
|
|
response_errors = parsed.get("errors") if is_record(parsed.get("errors")) else None
|
|
|
|
result: dict[str, str | None] = {}
|
|
for ref_id in ids:
|
|
if response_errors and ref_id in response_errors:
|
|
error_entry = response_errors[ref_id]
|
|
if is_record(error_entry) and isinstance(error_entry.get("message"), str):
|
|
msg = error_entry["message"].strip()
|
|
logger.warning("ExecProvider: error for id '%s': %s", ref_id, msg)
|
|
else:
|
|
logger.warning("ExecProvider: error for id '%s'", ref_id)
|
|
result[ref_id] = None
|
|
continue
|
|
|
|
if ref_id not in response_values:
|
|
logger.warning("ExecProvider: response missing id '%s'", ref_id)
|
|
result[ref_id] = None
|
|
continue
|
|
|
|
value = response_values[ref_id]
|
|
result[ref_id] = str(value) if not isinstance(value, str) else value
|
|
|
|
return result
|