378 lines
15 KiB
Python
378 lines
15 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Fail-closed command policy for the Hermes handoff acceptance harness.
|
||
|
|
|
||
|
|
Two properties have to hold structurally, not by convention:
|
||
|
|
|
||
|
|
* the default run mutates nothing, and
|
||
|
|
* no probe can pull a credential value into the report.
|
||
|
|
|
||
|
|
Both are enforced here, where an argv becomes a subprocess, so a mistake in the
|
||
|
|
check catalog surfaces as a policy error when the catalog is built rather than
|
||
|
|
as a mutation or a leak when it runs. `--as` is rejected on the operator side:
|
||
|
|
impersonation is not evidence about what Hermes can do, and a harness able to
|
||
|
|
impersonate would be tempted to substitute it for a real self-probe. It stays
|
||
|
|
available to the inner command of a `kubectl exec`, where an identity is
|
||
|
|
probing its own refusal to impersonate rather than borrowing authority.
|
||
|
|
|
||
|
|
Shell is reachable only through frozen templates. Free-form `sh -c` cannot be
|
||
|
|
screened reliably, but a few probes genuinely need field extraction (report that
|
||
|
|
a password field is locked without ever reading its hash) or an explicit helper
|
||
|
|
path, so those scripts are named, reviewed here, and parameter-validated instead
|
||
|
|
of assembled by callers.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import re
|
||
|
|
from collections.abc import Iterator
|
||
|
|
|
||
|
|
READ_ONLY = "read-only"
|
||
|
|
ARMED = "ephemeral-armed"
|
||
|
|
MODES = (READ_ONLY, ARMED)
|
||
|
|
|
||
|
|
# Rejected everywhere. Impersonation and inline credentials have no honest use
|
||
|
|
# in an acceptance probe.
|
||
|
|
FORBIDDEN_ARGS = (
|
||
|
|
"--as",
|
||
|
|
"--as-group",
|
||
|
|
"--as-uid",
|
||
|
|
"--token",
|
||
|
|
"--password",
|
||
|
|
"--username",
|
||
|
|
"--client-key",
|
||
|
|
"--client-certificate",
|
||
|
|
)
|
||
|
|
# Reachable only from inside a pod, where an identity is probing its own
|
||
|
|
# refusal to impersonate rather than borrowing someone else's authority.
|
||
|
|
IMPERSONATION_ARGS = ("--as", "--as-group", "--as-uid")
|
||
|
|
FORBIDDEN_BINARIES = frozenset({"vault", "sops", "age", "gpg", "openssl", "curl", "wget"})
|
||
|
|
GITEA_CLIENT = "/opt/coordinator/gitea_api.py"
|
||
|
|
ALLOWED_BINARIES = frozenset({"kubectl", "flux", "helm", "git", "hermes", "sh", GITEA_CLIENT})
|
||
|
|
|
||
|
|
# Flags that consume the following token, so it is never a subcommand.
|
||
|
|
VALUE_FLAGS = frozenset(
|
||
|
|
{
|
||
|
|
"-c",
|
||
|
|
"-l",
|
||
|
|
"-n",
|
||
|
|
"-o",
|
||
|
|
"--cluster",
|
||
|
|
"--container",
|
||
|
|
"--context",
|
||
|
|
"--field-selector",
|
||
|
|
"--kubeconfig",
|
||
|
|
"--namespace",
|
||
|
|
"--output",
|
||
|
|
"--request-timeout",
|
||
|
|
"--selector",
|
||
|
|
"--server",
|
||
|
|
"--since",
|
||
|
|
"--tail",
|
||
|
|
"--user",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
KUBECTL_READ_SUBCOMMANDS = frozenset(
|
||
|
|
{
|
||
|
|
"api-resources",
|
||
|
|
"api-versions",
|
||
|
|
"auth",
|
||
|
|
"cluster-info",
|
||
|
|
"config",
|
||
|
|
"describe",
|
||
|
|
"events",
|
||
|
|
"explain",
|
||
|
|
"get",
|
||
|
|
"logs",
|
||
|
|
"top",
|
||
|
|
"version",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
# Reachable only through a dry run: the API server authorises the request and
|
||
|
|
# discards it. A rejection is the evidence, and nothing is persisted either way.
|
||
|
|
KUBECTL_DRY_RUN_SUBCOMMANDS = frozenset(
|
||
|
|
{"annotate", "apply", "create", "delete", "label", "patch", "replace", "scale", "set"}
|
||
|
|
)
|
||
|
|
KUBECTL_EXEC_SUBCOMMANDS = frozenset({"exec"})
|
||
|
|
KUBECTL_SUBCOMMANDS = (
|
||
|
|
KUBECTL_READ_SUBCOMMANDS | KUBECTL_DRY_RUN_SUBCOMMANDS | KUBECTL_EXEC_SUBCOMMANDS
|
||
|
|
)
|
||
|
|
DRY_RUN_FLAGS = ("--dry-run=server", "--dry-run=client")
|
||
|
|
|
||
|
|
# Resources whose objects carry credential material. Reading them is permitted
|
||
|
|
# only in name-only output form, so a regression that grants access still cannot
|
||
|
|
# deliver a secret value into a report.
|
||
|
|
NAME_ONLY_RESOURCES = frozenset({"secret", "secrets"})
|
||
|
|
|
||
|
|
FLUX_READ_SUBCOMMANDS = frozenset(
|
||
|
|
{"check", "diff", "events", "export", "get", "stats", "trace", "tree", "version"}
|
||
|
|
)
|
||
|
|
HELM_READ_SUBCOMMANDS = frozenset({"get", "history", "list", "status", "version"})
|
||
|
|
GIT_READ_SUBCOMMANDS = frozenset(
|
||
|
|
{
|
||
|
|
"cat-file",
|
||
|
|
"config",
|
||
|
|
"diff",
|
||
|
|
"fetch",
|
||
|
|
"for-each-ref",
|
||
|
|
"log",
|
||
|
|
"ls-remote",
|
||
|
|
"ls-tree",
|
||
|
|
"merge-base",
|
||
|
|
"remote",
|
||
|
|
"rev-list",
|
||
|
|
"rev-parse",
|
||
|
|
"show",
|
||
|
|
"status",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
GIT_ARMED_SUBCOMMANDS = frozenset({"push"})
|
||
|
|
GIT_FORCE_FLAGS = frozenset({"-f", "--force", "--force-with-lease", "--mirror", "--all", "--tags"})
|
||
|
|
GITEA_READ_METHODS = frozenset({"GET"})
|
||
|
|
GITEA_ARMED_METHODS = frozenset({"POST", "PATCH", "DELETE"})
|
||
|
|
|
||
|
|
# The Hermes CLI is the only honest source for durable session and Kanban
|
||
|
|
# activity, so a narrow read-only slice of it is on the allowlist.
|
||
|
|
HERMES_READ_COMMANDS = frozenset({("kanban", "list"), ("kanban", "show"), ("sessions", "list"), ("status",)})
|
||
|
|
|
||
|
|
# Paths that hold credential material. A probe may prove one exists or is
|
||
|
|
# unreadable; it may never read it outside a frozen template.
|
||
|
|
SECRET_PATH_RE = re.compile(
|
||
|
|
r"(?:^|/)(?:vault/secrets|var/run/secrets|runtime-access|\.credentials\.json"
|
||
|
|
r"|auth\.json|authorized_keys|id_[a-z0-9]+|\.netrc|shadow|gshadow)(?:$|/)"
|
||
|
|
)
|
||
|
|
|
||
|
|
_PARAMETER_RE = re.compile(r"[A-Za-z0-9/][A-Za-z0-9._/@:,-]{0,255}\Z")
|
||
|
|
_PLACEHOLDER_RE = re.compile(r"\{([a-z_]+)\}")
|
||
|
|
|
||
|
|
# Frozen shell templates. Each exists because a plain argv cannot express the
|
||
|
|
# safety property its probe needs.
|
||
|
|
SHELL_TEMPLATES: dict[str, str] = {
|
||
|
|
# Report an account's lock state without reading its password hash.
|
||
|
|
"account_lock_state": (
|
||
|
|
'awk -F: \'$1=="{account}"{{print ($2=="!"||$2=="*"||$2=="!!")?"locked":"unlocked"}}\' {path}'
|
||
|
|
),
|
||
|
|
# Report the account record's non-secret identity fields.
|
||
|
|
"account_identity": 'awk -F: \'$1=="{account}"{{print $3":"$4":"$6":"$7}}\' {path}',
|
||
|
|
# Report supplementary group names the account joins, never their members.
|
||
|
|
"account_groups": "awk -F: '$4~/(^|,){account}(,|$)/{{print $1}}' {path}",
|
||
|
|
# Prove a path's presence and mode without reading its bytes.
|
||
|
|
"path_mode": 'if [ -e {path} ]; then stat -c "%a %U:%G" {path}; else echo absent; fi',
|
||
|
|
# Prove whether a path is readable by this identity, without printing it.
|
||
|
|
"path_readable": 'if [ -r {path} ]; then echo readable; else echo unreadable; fi',
|
||
|
|
# List environment variable names only. Values never leave the container.
|
||
|
|
"env_names": "env | sed -n 's/^\\([A-Za-z_][A-Za-z0-9_]*\\)=.*/\\1/p' | sort",
|
||
|
|
# Report a JSON document's top-level key names without any of its values.
|
||
|
|
"json_keys": (
|
||
|
|
'python3 -c "import json,sys;print(chr(10).join(sorted(json.load(open(sys.argv[1])))))" {path}'
|
||
|
|
),
|
||
|
|
# Report allow-listed non-secret status fields from a provider health file.
|
||
|
|
"json_fields": (
|
||
|
|
'python3 -c "import json,sys;d=json.load(open(sys.argv[1]));'
|
||
|
|
'print(json.dumps({{k:d.get(k) for k in sys.argv[2].split(chr(44))}}))" {path} {fields}'
|
||
|
|
),
|
||
|
|
# Count non-empty lines, for authorized-key inventories.
|
||
|
|
"line_count": "awk 'NF{{n++}} END{{print n+0}}' {path}",
|
||
|
|
# Tail an append-only evidence log. The harness parses it; the container is
|
||
|
|
# not assumed to carry an interpreter.
|
||
|
|
"tail_lines": "tail -n {limit} {path}",
|
||
|
|
# Prove the worker can still read the repository. `kubectl exec` does not
|
||
|
|
# inherit the coordinator process environment, so the askpass helper is
|
||
|
|
# named explicitly rather than assumed; the helper reads runtime state and
|
||
|
|
# the credential never reaches this command line.
|
||
|
|
"git_ls_remote": "GIT_ASKPASS={askpass} git ls-remote {url} refs/heads/main",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class PolicyError(ValueError):
|
||
|
|
"""Raised when a requested command falls outside the safe boundary."""
|
||
|
|
|
||
|
|
|
||
|
|
def template_parameters(template_name: str) -> set[str]:
|
||
|
|
"""Return the parameter names a frozen template requires."""
|
||
|
|
template = SHELL_TEMPLATES.get(template_name)
|
||
|
|
if template is None:
|
||
|
|
raise PolicyError(f"unknown shell template: {template_name}")
|
||
|
|
return set(_PLACEHOLDER_RE.findall(template))
|
||
|
|
|
||
|
|
|
||
|
|
def render_shell(template_name: str, **parameters: str) -> str:
|
||
|
|
"""Return a frozen shell template with validated parameters substituted."""
|
||
|
|
required = template_parameters(template_name)
|
||
|
|
template = SHELL_TEMPLATES[template_name]
|
||
|
|
if set(parameters) != required:
|
||
|
|
raise PolicyError(f"shell template {template_name} expects parameters {sorted(required)}")
|
||
|
|
for key, value in parameters.items():
|
||
|
|
if not _PARAMETER_RE.fullmatch(value):
|
||
|
|
raise PolicyError(f"unsafe shell parameter for {template_name}.{key}")
|
||
|
|
return template.format(**parameters)
|
||
|
|
|
||
|
|
|
||
|
|
_RENDERED: set[str] = set()
|
||
|
|
|
||
|
|
|
||
|
|
def shell(template_name: str, **parameters: str) -> tuple[str, ...]:
|
||
|
|
"""Return a validated ``sh -c`` argv built from a frozen template."""
|
||
|
|
script = render_shell(template_name, **parameters)
|
||
|
|
_RENDERED.add(script)
|
||
|
|
return ("sh", "-c", script)
|
||
|
|
|
||
|
|
|
||
|
|
def positionals(argv: tuple[str, ...], start: int = 1) -> Iterator[str]:
|
||
|
|
"""Yield positional arguments, skipping flags and the values they consume."""
|
||
|
|
index = start
|
||
|
|
while index < len(argv):
|
||
|
|
item = argv[index]
|
||
|
|
if item == "--":
|
||
|
|
return
|
||
|
|
if item.startswith("-"):
|
||
|
|
if item in VALUE_FLAGS:
|
||
|
|
index += 2
|
||
|
|
continue
|
||
|
|
index += 1
|
||
|
|
continue
|
||
|
|
yield item
|
||
|
|
index += 1
|
||
|
|
|
||
|
|
|
||
|
|
def _reject_forbidden_arguments(argv: tuple[str, ...], allow_impersonation: bool = False) -> None:
|
||
|
|
permitted = IMPERSONATION_ARGS if allow_impersonation else ()
|
||
|
|
boundary = argv.index("--") if "--" in argv else len(argv)
|
||
|
|
for argument in argv[:boundary]:
|
||
|
|
for forbidden in FORBIDDEN_ARGS:
|
||
|
|
if forbidden in permitted:
|
||
|
|
continue
|
||
|
|
if argument == forbidden or argument.startswith(f"{forbidden}="):
|
||
|
|
raise PolicyError(f"forbidden argument: {forbidden}")
|
||
|
|
|
||
|
|
|
||
|
|
def _reject_secret_paths(argv: tuple[str, ...]) -> None:
|
||
|
|
boundary = argv.index("--") if "--" in argv else len(argv)
|
||
|
|
for argument in argv[:boundary]:
|
||
|
|
if SECRET_PATH_RE.search(argument):
|
||
|
|
raise PolicyError("probes may not name a credential path outside a frozen template")
|
||
|
|
|
||
|
|
|
||
|
|
def _assert_name_only_output(argv: tuple[str, ...]) -> None:
|
||
|
|
if "--output=name" in argv or "-oname" in argv:
|
||
|
|
return
|
||
|
|
for index, item in enumerate(argv[:-1]):
|
||
|
|
if item in {"-o", "--output"} and argv[index + 1] == "name":
|
||
|
|
return
|
||
|
|
raise PolicyError("credential-bearing resources may only be read with -o name")
|
||
|
|
|
||
|
|
|
||
|
|
def _check_kubectl(argv: tuple[str, ...], mode: str, allow_impersonation: bool) -> None:
|
||
|
|
words = list(positionals(argv))
|
||
|
|
subcommand = next((word for word in words if word in KUBECTL_SUBCOMMANDS), None)
|
||
|
|
if subcommand is None:
|
||
|
|
raise PolicyError("kubectl subcommand is outside the read-only boundary")
|
||
|
|
if subcommand != words[0]:
|
||
|
|
raise PolicyError("kubectl subcommand must precede its resource arguments")
|
||
|
|
resource = words[1].split(".")[0] if len(words) > 1 else ""
|
||
|
|
if subcommand in {"get", "describe"} and resource in NAME_ONLY_RESOURCES:
|
||
|
|
_assert_name_only_output(argv)
|
||
|
|
if subcommand in KUBECTL_READ_SUBCOMMANDS:
|
||
|
|
return
|
||
|
|
if subcommand in KUBECTL_EXEC_SUBCOMMANDS:
|
||
|
|
if "--" not in argv:
|
||
|
|
raise PolicyError("kubectl exec requires an explicit -- command boundary")
|
||
|
|
inner = argv[argv.index("--") + 1 :]
|
||
|
|
if not inner:
|
||
|
|
raise PolicyError("kubectl exec requires an inner command")
|
||
|
|
check_argv(inner, mode, allow_impersonation=True)
|
||
|
|
return
|
||
|
|
if any(flag in argv for flag in DRY_RUN_FLAGS):
|
||
|
|
return
|
||
|
|
raise PolicyError(f"kubectl {subcommand} requires a dry run")
|
||
|
|
|
||
|
|
|
||
|
|
def _first_positional(binary: str, argv: tuple[str, ...]) -> str:
|
||
|
|
word = next(positionals(argv), None)
|
||
|
|
if word is None:
|
||
|
|
raise PolicyError(f"{binary} requires a subcommand")
|
||
|
|
return word
|
||
|
|
|
||
|
|
|
||
|
|
def _check_read_only_tool(binary: str, argv: tuple[str, ...], allowed: frozenset[str]) -> None:
|
||
|
|
subcommand = _first_positional(binary, argv)
|
||
|
|
if subcommand not in allowed:
|
||
|
|
raise PolicyError(f"{binary} {subcommand} is outside the read-only boundary")
|
||
|
|
|
||
|
|
|
||
|
|
def _check_git(argv: tuple[str, ...], mode: str) -> None:
|
||
|
|
subcommand = _first_positional("git", argv)
|
||
|
|
if subcommand == "config" and not {"--get", "--get-all", "--list"} & set(argv):
|
||
|
|
raise PolicyError("git config may only read")
|
||
|
|
if subcommand in GIT_READ_SUBCOMMANDS:
|
||
|
|
return
|
||
|
|
if subcommand in GIT_ARMED_SUBCOMMANDS and mode == ARMED:
|
||
|
|
if GIT_FORCE_FLAGS & set(argv):
|
||
|
|
raise PolicyError("git push may not force or fan out across refs")
|
||
|
|
return
|
||
|
|
raise PolicyError(f"git {subcommand} is not permitted in {mode} mode")
|
||
|
|
|
||
|
|
|
||
|
|
def _check_hermes(argv: tuple[str, ...]) -> None:
|
||
|
|
words = tuple(positionals(argv))
|
||
|
|
if not words:
|
||
|
|
raise PolicyError("hermes requires a subcommand")
|
||
|
|
if words[:2] not in HERMES_READ_COMMANDS and words[:1] not in HERMES_READ_COMMANDS:
|
||
|
|
raise PolicyError(f"hermes {' '.join(words[:2])} is outside the read-only boundary")
|
||
|
|
|
||
|
|
|
||
|
|
def _check_gitea(argv: tuple[str, ...], mode: str) -> None:
|
||
|
|
if len(argv) < 3:
|
||
|
|
raise PolicyError("gitea_api.py requires a method and a path")
|
||
|
|
method = argv[1].upper()
|
||
|
|
if method in GITEA_READ_METHODS:
|
||
|
|
return
|
||
|
|
if method in GITEA_ARMED_METHODS and mode == ARMED:
|
||
|
|
return
|
||
|
|
raise PolicyError(f"gitea_api.py {method} is not permitted in {mode} mode")
|
||
|
|
|
||
|
|
|
||
|
|
def _check_shell(argv: tuple[str, ...]) -> None:
|
||
|
|
if len(argv) != 3 or argv[1] != "-c":
|
||
|
|
raise PolicyError("sh is only reachable as sh -c <frozen template>")
|
||
|
|
if argv[2] not in _RENDERED:
|
||
|
|
raise PolicyError("sh -c script is not a frozen template rendering")
|
||
|
|
|
||
|
|
|
||
|
|
def check_argv(
|
||
|
|
argv: tuple[str, ...], mode: str = READ_ONLY, allow_impersonation: bool = False
|
||
|
|
) -> None:
|
||
|
|
"""Raise :class:`PolicyError` unless ``argv`` is safe to run in ``mode``.
|
||
|
|
|
||
|
|
``allow_impersonation`` is set only for the inner command of a
|
||
|
|
``kubectl exec``. Hermes probing its own refusal to impersonate is evidence;
|
||
|
|
the operator impersonating Hermes is not, so `--as` stays unreachable from
|
||
|
|
the outer, operator-side argv.
|
||
|
|
"""
|
||
|
|
if mode not in MODES:
|
||
|
|
raise PolicyError(f"unknown mode: {mode}")
|
||
|
|
if not argv:
|
||
|
|
raise PolicyError("empty command")
|
||
|
|
binary = argv[0] if argv[0] == GITEA_CLIENT else argv[0].rsplit("/", 1)[-1]
|
||
|
|
if binary in FORBIDDEN_BINARIES:
|
||
|
|
raise PolicyError(f"forbidden binary: {binary}")
|
||
|
|
if binary not in ALLOWED_BINARIES:
|
||
|
|
raise PolicyError(f"binary is not on the harness allowlist: {binary}")
|
||
|
|
_reject_forbidden_arguments(argv, allow_impersonation)
|
||
|
|
if binary == "sh":
|
||
|
|
_check_shell(argv)
|
||
|
|
return
|
||
|
|
_reject_secret_paths(argv)
|
||
|
|
if binary == "kubectl":
|
||
|
|
_check_kubectl(argv, mode, allow_impersonation)
|
||
|
|
elif binary == "flux":
|
||
|
|
_check_read_only_tool("flux", argv, FLUX_READ_SUBCOMMANDS)
|
||
|
|
elif binary == "helm":
|
||
|
|
_check_read_only_tool("helm", argv, HELM_READ_SUBCOMMANDS)
|
||
|
|
elif binary == "git":
|
||
|
|
_check_git(argv, mode)
|
||
|
|
elif binary == "hermes":
|
||
|
|
_check_hermes(argv)
|
||
|
|
else:
|
||
|
|
_check_gitea(argv, mode)
|