Bind credential exec to exact owner inputs and approval digest
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
9eb07fd8fc
commit
42b48aa54f
13 changed files with 626 additions and 7 deletions
|
|
@ -105,6 +105,12 @@ def build_action_request(
|
|||
"context": {"purpose": purpose},
|
||||
}
|
||||
)
|
||||
if action == "exec":
|
||||
from secrets_engine.exec_owner import owner_digest
|
||||
|
||||
digest = owner_digest(entry)
|
||||
if digest is not None:
|
||||
request["context"]["exec_owner_sha256"] = digest
|
||||
return request
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -360,6 +360,16 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
|
|||
f"{source}: high-risk lanes require {lifecycle_name}.owner"
|
||||
)
|
||||
|
||||
delivery_config = data.get("delivery_config", {})
|
||||
if not isinstance(delivery_config, dict):
|
||||
raise CatalogError(f"{source}: delivery_config must be a mapping")
|
||||
if "exec_owner" in delivery_config:
|
||||
from secrets_engine.exec_owner import validate_exec_owner
|
||||
|
||||
validate_exec_owner(delivery_config["exec_owner"])
|
||||
if "exec-env" not in modes or set(modes) - {"exec-env", "read-check"}:
|
||||
raise CatalogError(f"{source}: exec_owner permits only exec-env and read-check")
|
||||
|
||||
# npm-config delivery must declare WHERE it publishes (registry + scope), so
|
||||
# the registry is catalog data, never hardcoded in the engine.
|
||||
if "npm-config" in modes:
|
||||
|
|
|
|||
|
|
@ -542,9 +542,12 @@ def cmd_wrap(cfg: Config, args) -> int:
|
|||
|
||||
def cmd_exec(cfg: Config, args) -> int:
|
||||
from secrets_engine.exec_delivery import exec_with_secret
|
||||
from secrets_engine.exec_owner import validate_delivery_target
|
||||
entry = get_entry(cfg.catalog_dir, args.catalog)
|
||||
field = args.field or (entry.fields[0] if entry.fields else "")
|
||||
session_detail: dict[str, object] = {}
|
||||
# Refuse a substituted recipient before consuming approval or opening Bao.
|
||||
owner_digest = validate_delivery_target(entry, field, args.command, args.mode)
|
||||
command_name = args.command[0] if args.command else ""
|
||||
with _privileged_evidence(
|
||||
cfg,
|
||||
|
|
@ -555,6 +558,7 @@ def cmd_exec(cfg: Config, args) -> int:
|
|||
"mode": args.mode,
|
||||
"field": field,
|
||||
"session": session_detail,
|
||||
"exec_owner_sha256": owner_digest,
|
||||
},
|
||||
) as evidence:
|
||||
# require approval + readiness before running.
|
||||
|
|
@ -575,6 +579,7 @@ def cmd_exec(cfg: Config, args) -> int:
|
|||
args.command,
|
||||
mode=args.mode,
|
||||
session_evidence=session_detail,
|
||||
expected_owner_digest=owner_digest,
|
||||
)
|
||||
evidence.finish(f"exit-{rc}")
|
||||
return rc
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from typing import Iterator
|
|||
|
||||
from secrets_engine.catalog import CatalogEntry
|
||||
from secrets_engine.errors import DeliveryError
|
||||
from secrets_engine.exec_owner import owner_binding, validate_delivery_target
|
||||
from secrets_engine.openbao import OpenBaoClient
|
||||
from secrets_engine.publication_policy import PublicationPolicy, resolve
|
||||
from secrets_engine.redact import redact_text
|
||||
|
|
@ -180,6 +181,7 @@ def exec_with_secret(
|
|||
mode: str = "auto",
|
||||
policy_dir=None,
|
||||
session_evidence: dict[str, object] | None = None,
|
||||
expected_owner_digest: str | None = None,
|
||||
) -> int:
|
||||
"""Run `command` with the lane's secret injected for the child only.
|
||||
|
||||
|
|
@ -192,6 +194,10 @@ def exec_with_secret(
|
|||
f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}"
|
||||
)
|
||||
|
||||
binding_digest = validate_delivery_target(entry, field, command, mode)
|
||||
if expected_owner_digest is not None and binding_digest != expected_owner_digest:
|
||||
raise DeliveryError("exec owner binding changed after action admission")
|
||||
|
||||
declared = set(entry.delivery_modes)
|
||||
if mode == "auto":
|
||||
if "npm-config" in declared:
|
||||
|
|
@ -219,7 +225,12 @@ def exec_with_secret(
|
|||
value = _fetch_value(
|
||||
client, entry, field, session_evidence=session_evidence
|
||||
)
|
||||
child_env = dict(os.environ)
|
||||
# Recheck after retrieval too: a config changed during auth/read cannot be
|
||||
# launched with a value authorized for the previous recipient.
|
||||
if validate_delivery_target(entry, field, command, mode) != binding_digest:
|
||||
raise DeliveryError("exec owner binding changed during credential retrieval")
|
||||
binding = owner_binding(entry)
|
||||
child_env = dict(binding["environment"]) if binding is not None else dict(os.environ)
|
||||
|
||||
if mode == "npm-config":
|
||||
npm = entry.npm
|
||||
|
|
@ -241,6 +252,8 @@ def exec_with_secret(
|
|||
# Inject under a conventional name derived from the field.
|
||||
env_name = field.upper()
|
||||
child_env[env_name] = value
|
||||
if binding is not None:
|
||||
return _spawn(command, child_env, value, cwd=binding["cwd"])
|
||||
return _spawn(command, child_env, value)
|
||||
|
||||
if mode == "exec-file":
|
||||
|
|
@ -252,7 +265,7 @@ def exec_with_secret(
|
|||
raise DeliveryError(f"unsupported delivery mode '{mode}'")
|
||||
|
||||
|
||||
def _spawn(command: list[str], env: dict[str, str], secret: str) -> int:
|
||||
def _spawn(command: list[str], env: dict[str, str], secret: str, *, cwd: str | None = None) -> int:
|
||||
"""Spawn the child, stream redacted output, propagate signals, ensure cleanup."""
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
|
|
@ -261,6 +274,8 @@ def _spawn(command: list[str], env: dict[str, str], secret: str) -> int:
|
|||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
stdin=subprocess.DEVNULL if cwd is not None else None,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise DeliveryError(f"command not found: {command[0]}") from e
|
||||
|
|
|
|||
124
src/secrets_engine/exec_owner.py
Normal file
124
src/secrets_engine/exec_owner.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Catalog-bound child delivery. Configuration constrains a grant; it grants none.
|
||||
|
||||
The engine and configured owner UID remain trusted. These checks do not fence a
|
||||
malicious process with that same UID or replace runtime/profile verification.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
from secrets_engine.errors import CatalogError, DeliveryError
|
||||
|
||||
|
||||
def _text(value: object) -> bool:
|
||||
return isinstance(value, str) and bool(value.strip()) and "\0" not in value
|
||||
|
||||
|
||||
def _absolute(value: object) -> bool:
|
||||
return _text(value) and Path(value).is_absolute() and str(Path(value)) == value and ".." not in Path(value).parts
|
||||
|
||||
|
||||
def validate_exec_owner(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or not _text(value.get("owner")):
|
||||
raise CatalogError("delivery_config.exec_owner requires a named owner")
|
||||
if value.get("status") == "pending":
|
||||
if set(value) != {"status", "owner", "reason"} or not _text(value.get("reason")):
|
||||
raise CatalogError("pending exec_owner requires only status, owner and reason")
|
||||
return value
|
||||
required = {"status", "owner", "command", "cwd", "environment", "files"}
|
||||
if value.get("status") != "configured" or set(value) != required:
|
||||
raise CatalogError("configured exec_owner requires exact command/cwd/environment/files")
|
||||
command = value["command"]
|
||||
if not isinstance(command, list) or not command or not all(_text(x) for x in command) or not _absolute(command[0]):
|
||||
raise CatalogError("exec_owner command must be an exact argv with an absolute executable")
|
||||
if not _absolute(value["cwd"]):
|
||||
raise CatalogError("exec_owner cwd must be an absolute canonical directory")
|
||||
env = value["environment"]
|
||||
if not isinstance(env, dict) or not all(
|
||||
isinstance(k, str) and re.fullmatch(r"[A-Z_][A-Z0-9_]*", k)
|
||||
and isinstance(v, str) and "\0" not in v for k, v in env.items()
|
||||
):
|
||||
raise CatalogError("exec_owner environment must be an explicit string mapping")
|
||||
# The fixed environment is non-secret source configuration, not a way to
|
||||
# carry the invoking engine's credentials or loader injection into the child.
|
||||
if any(k.startswith(("LD_", "DYLD_", "PYTHON", "BAO_", "VAULT_", "SECRETS_ENGINE_", "ANTHROPIC_", "CLAUDE_")) for k in env):
|
||||
raise CatalogError("exec_owner environment contains a forbidden credential/loader variable")
|
||||
files = value["files"]
|
||||
if not isinstance(files, dict) or command[0] not in files:
|
||||
raise CatalogError("exec_owner files must pin the executable")
|
||||
for path, spec in files.items():
|
||||
if not _absolute(path) or not isinstance(spec, dict) or set(spec) != {"sha256", "private"}:
|
||||
raise CatalogError("exec_owner files require canonical paths and sha256/private")
|
||||
if not isinstance(spec["sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}", spec["sha256"]) or type(spec["private"]) is not bool:
|
||||
raise CatalogError("exec_owner file pin must have an exact SHA-256 and boolean private")
|
||||
for arg in command[1:]:
|
||||
if arg.startswith("/") and arg not in files:
|
||||
raise CatalogError("exec_owner absolute file arguments must have file pins")
|
||||
return value
|
||||
|
||||
|
||||
def owner_binding(entry) -> dict[str, Any] | None:
|
||||
config = entry.delivery_config
|
||||
if "exec_owner" not in config:
|
||||
return None
|
||||
return validate_exec_owner(config["exec_owner"])
|
||||
|
||||
|
||||
def owner_digest(entry) -> str | None:
|
||||
binding = owner_binding(entry)
|
||||
if binding is None:
|
||||
return None
|
||||
return hashlib.sha256(json.dumps(binding, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
|
||||
|
||||
|
||||
def _check_path(path: Path, *, directory: bool = False, private: bool = False) -> None:
|
||||
"""No symlink or untrusted writable ancestor; root-owned sticky /tmp is safe."""
|
||||
for candidate in [*reversed(path.parents), path]:
|
||||
info = candidate.lstat()
|
||||
leaf = candidate == path
|
||||
is_dir = not leaf or directory
|
||||
if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)):
|
||||
raise DeliveryError("exec owner path is not a regular file/directory")
|
||||
if info.st_uid not in {0, os.getuid()}:
|
||||
raise DeliveryError("exec owner path has an untrusted owner")
|
||||
sticky_root = not leaf and info.st_uid == 0 and bool(info.st_mode & stat.S_ISVTX)
|
||||
if info.st_mode & 0o022 and not sticky_root:
|
||||
raise DeliveryError("exec owner path is writable by another identity")
|
||||
if leaf and private and info.st_mode & 0o077:
|
||||
raise DeliveryError("exec owner private path is accessible by another identity")
|
||||
|
||||
|
||||
def validate_delivery_target(entry, field: str, command: list[str], mode: str) -> str | None:
|
||||
binding = owner_binding(entry)
|
||||
if binding is None:
|
||||
return None
|
||||
if binding["status"] != "configured":
|
||||
raise DeliveryError("exec owner binding is pending; no delivery is admitted")
|
||||
if mode not in {"auto", "exec-env"} or "exec-env" not in entry.delivery_modes:
|
||||
raise DeliveryError("exec owner requires exec-env delivery")
|
||||
if field not in entry.fields or field.upper() in binding["environment"]:
|
||||
raise DeliveryError("exec owner field is undeclared or conflicts with fixed environment")
|
||||
if command != binding["command"]:
|
||||
raise DeliveryError("command differs from the catalog-bound exec owner")
|
||||
try:
|
||||
_check_path(Path(binding["cwd"]), directory=True, private=True)
|
||||
for name, pin in binding["files"].items():
|
||||
path = Path(name)
|
||||
_check_path(path, private=pin["private"])
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(fd, "rb") as source:
|
||||
if not stat.S_ISREG(os.fstat(source.fileno()).st_mode):
|
||||
raise DeliveryError("exec owner file changed type")
|
||||
if hashlib.file_digest(source, "sha256").hexdigest() != pin["sha256"]:
|
||||
raise DeliveryError("exec owner file digest differs from catalog")
|
||||
if not os.access(command[0], os.X_OK):
|
||||
raise DeliveryError("exec owner executable is not executable")
|
||||
except OSError as exc:
|
||||
raise DeliveryError("exec owner path is unavailable or unsafe") from exc
|
||||
return owner_digest(entry)
|
||||
|
|
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
from typing import Any
|
||||
|
||||
from secrets_engine.catalog import CatalogEntry
|
||||
|
|
@ -78,8 +79,17 @@ def route_lane(
|
|||
|
||||
approved = not entry.approval_required() or (decision is not None and decision.is_approved())
|
||||
ready = approved and metadata_applied and value_present
|
||||
from secrets_engine.exec_owner import owner_binding
|
||||
|
||||
if not approved:
|
||||
binding = owner_binding(entry)
|
||||
owner_pending = binding is not None and binding["status"] == "pending"
|
||||
if owner_pending:
|
||||
ready = False
|
||||
|
||||
if owner_pending:
|
||||
missing = "configured exec owner command, private inputs and environment"
|
||||
next_command = f"secrets-engine catalog show {entry.id}"
|
||||
elif not approved:
|
||||
missing = f"approved decision for '{decision_ref}'"
|
||||
next_command = f"secrets-engine decision inspect {decision_ref or entry.id}"
|
||||
elif not metadata_applied:
|
||||
|
|
@ -112,7 +122,8 @@ def route_lane(
|
|||
else:
|
||||
missing = ""
|
||||
if {"exec-env", "npm-config"}.intersection(entry.delivery_modes):
|
||||
next_command = f"secrets-engine exec --catalog {entry.id} -- <command...>"
|
||||
command = shlex.join(binding["command"]) if binding is not None else "<command...>"
|
||||
next_command = f"secrets-engine exec --catalog {entry.id} -- {command}"
|
||||
else:
|
||||
next_command = (
|
||||
f"secrets-engine verify {entry.id} --positive --negative "
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue