Decides whether the Hermes platform handoff is fit to release, and refuses to round an absence of evidence up to a pass. The harness is read-only by default and classifies 71 checks PASS / FAIL / NOT_RUN / NOT_APPLICABLE. Any mandatory FAIL or NOT_RUN is NO_GO, and so is a harness-level problem: an unreachable vantage, a catalog entry whose evidence no longer exists, an expired deadline, or an evaluator that raised. Evidence comes from two vantages that cannot cover for each other: an external read-only operator kubeconfig, and the Hermes agent probing itself from inside its own pod. Before any check runs, the harness asks each vantage who it is and stops if they are the same principal, because dual-vantage evidence from one identity is a restatement rather than a corroboration. `--as` is rejected for every operator-side command and reachable only as the inner command of a `kubectl exec`, so impersonation can never stand in for a real self-probe. A deny check needs a live refused request, not only an authorization review. Two safety properties are structural rather than conventional, enforced where an argv becomes a subprocess: the default mode mutates nothing (mutating verbs require a server dry run; there is deliberately no live TokenRequest probe, because a successful one would mint a real credential), and no probe can pull a credential value into a report (no vault/sops/curl, secrets readable only with -o name, environment probes list names, shell only through frozen reviewed templates). Captures are bounded before they are screened, and the rendered report is re-screened before it is written. Mutation lives behind a separate arming flag with an exact confirmation phrase, a caller-supplied unique ref, a preflight that refuses a protected push target before any network call, and a cleanup whose verification is itself mandatory. A default run reports those four checks NOT_RUN. The catalog is declarative so a reviewer reads what is asserted rather than how it is plumbed, and so structural properties can be proven over every entry before a run. Catalog drift surfaces as NOT_RUN, which stops the release. docs/hermes_full_handoff_acceptance.md carries the merge order for PRs #14-#18 on top of the merged #13 baseline, the image build and Flux rollout, the rollback point for each step, the go/no-go checklist, and the limits that are asserted rather than exercised. Validation: 295 handoff tests pass with 100% line coverage on all 15 new modules; the full unit suite is 647 passed with two failures that reproduce unchanged on origin/main; Ruff, py_compile, kustomize render, and a diff credential screen are clean; a live read-only run against Atlas returns NO_GO for the pre-merge cluster with no unscreened fields in the report.
224 lines
8.0 KiB
Python
224 lines
8.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Credential redaction and output bounding for the handoff acceptance harness.
|
|
|
|
Every string the harness records — command output, failure reasons, evidence
|
|
values — passes through :func:`redact` before it reaches a report. Screening is
|
|
a fail-closed accident barrier, not proof that text is secret-free: a short
|
|
secret under an innocuous key is not reliably distinguishable from prose. The
|
|
harness therefore also never asks for a secret in the first place. Probes read
|
|
names, shapes, and status fields; they never read environment values, token
|
|
files, private keys, cookies, or Vault responses.
|
|
|
|
Bounding is part of the same barrier. An unbounded capture is how a debug dump
|
|
or a stack trace carrying an authorization header ends up in an artifact, so
|
|
captures are truncated at a byte budget before they are screened.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
REDACTED = "[redacted]"
|
|
DEFAULT_MAX_BYTES = 64 * 1024
|
|
TRUNCATION_NOTE = "[truncated]"
|
|
|
|
# Keys whose value is never safe to record, matched against a normalised
|
|
# (lowercased, separator-stripped) form of the key.
|
|
SENSITIVE_KEY_SUFFIXES = (
|
|
"accesskey",
|
|
"accesskeyid",
|
|
"apikey",
|
|
"authtoken",
|
|
"clientsecret",
|
|
"connectionstring",
|
|
"cookie",
|
|
"credential",
|
|
"credentials",
|
|
"passphrase",
|
|
"passwd",
|
|
"password",
|
|
"privatekey",
|
|
"secret",
|
|
"secretkey",
|
|
"sessionkey",
|
|
"signature",
|
|
"signingkey",
|
|
"token",
|
|
"webhook",
|
|
)
|
|
SENSITIVE_KEY_EXACT = frozenset(
|
|
{
|
|
"auth",
|
|
"authorization",
|
|
"bearer",
|
|
"pat",
|
|
"pw",
|
|
"sas",
|
|
"setcookie",
|
|
}
|
|
)
|
|
# A bare `key:` is a structural field name across Kubernetes and TOML far more
|
|
# often than it is a credential, so it is screened by value shape and by the
|
|
# token patterns rather than by name. The compound forms above still match.
|
|
|
|
# Values that cannot hold a credential: booleans, numbers, and anything too
|
|
# short to be one. Screening them corrupts structured evidence — a redacted
|
|
# `automountServiceAccountToken` is exactly the fact a reader needs — without
|
|
# protecting anything.
|
|
MIN_SECRET_LENGTH = 8
|
|
NON_SECRET_LITERALS = frozenset({"true", "false", "null", "none", "yes", "no", "0", "1", ""})
|
|
_KEY_NOISE_RE = re.compile(r"[^a-z0-9]+")
|
|
|
|
# Ordered: structural matches first so a nested token is not partially rewritten
|
|
# by a looser rule.
|
|
_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
re.compile(
|
|
r"-----BEGIN[ A-Z0-9-]{0,64}-----.*?-----END[ A-Z0-9-]{0,64}-----",
|
|
re.DOTALL,
|
|
),
|
|
re.compile(r"(?i)\b(?:authorization|proxy-authorization)\s*:\s*\S.*"),
|
|
re.compile(r"(?i)\b(?:set-)?cookie\s*:\s*\S.*"),
|
|
re.compile(r"(?i)\b(?:bearer|basic|token)\s+[A-Za-z0-9+/_=.:~-]{8,}"),
|
|
re.compile(r"(?i)\b[a-z][a-z0-9+.-]{1,20}://[^\s/@:]{1,128}:[^\s/@]{1,256}@"),
|
|
re.compile(r"\bssh-(?:rsa|ed25519|dss|ecdsa-[A-Za-z0-9-]+)\s+[A-Za-z0-9+/]{16,}={0,3}"),
|
|
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}"),
|
|
re.compile(r"(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{16,}"),
|
|
re.compile(r"(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{16,}"),
|
|
re.compile(r"(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{16,}"),
|
|
re.compile(r"(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{16,}"),
|
|
re.compile(r"(?<![A-Za-z0-9])sk-(?:ant-|proj-)?[A-Za-z0-9_-]{16,}"),
|
|
re.compile(r"(?<![A-Za-z0-9])(?:gta|gto|gitea|forgejo)_[A-Za-z0-9_-]{16,}"),
|
|
re.compile(r"(?<![A-Za-z0-9])hvs\.[A-Za-z0-9_-]{16,}"),
|
|
re.compile(r"(?<![A-Za-z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}"),
|
|
re.compile(r"(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}"),
|
|
re.compile(r"(?<![A-Za-z0-9])npm_[A-Za-z0-9]{16,}"),
|
|
)
|
|
|
|
# `key: value` / `key=value` where the key names a credential.
|
|
_ASSIGNMENT_RE = re.compile(
|
|
r"""(?x)
|
|
(?P<quote>["']?)
|
|
(?P<key>[A-Za-z][A-Za-z0-9_.\ -]{0,63})
|
|
(?P=quote)
|
|
(?P<gap>\s{0,8}[:=]\s{0,8})
|
|
(?P<value>"[^"\n]{0,4096}"|'[^'\n]{0,4096}'|[^\s,;}\n][^,;}\n]{0,4096})
|
|
"""
|
|
)
|
|
|
|
# Long, mixed-class runs. Deliberately conservative: prose and hex digests of
|
|
# fewer than 44 characters are left alone so commit SHAs stay readable.
|
|
_ENTROPY_RE = re.compile(r"(?<![A-Za-z0-9+/_=-])[A-Za-z0-9+/_=-]{44,}(?![A-Za-z0-9+/_=-])")
|
|
|
|
|
|
def normalise_key(key: str) -> str:
|
|
"""Return a key lowercased with separators removed, for suffix matching."""
|
|
return _KEY_NOISE_RE.sub("", key.lower())
|
|
|
|
|
|
def is_sensitive_key(key: str) -> bool:
|
|
"""Report whether a key name means its value must never be recorded."""
|
|
compact = normalise_key(key)
|
|
if not compact:
|
|
return False
|
|
if compact in SENSITIVE_KEY_EXACT:
|
|
return True
|
|
return compact.endswith(SENSITIVE_KEY_SUFFIXES)
|
|
|
|
|
|
def _is_high_entropy(candidate: str) -> bool:
|
|
"""Report whether a long run mixes enough character classes to be a token."""
|
|
classes = sum(
|
|
(
|
|
any(character.islower() for character in candidate),
|
|
any(character.isupper() for character in candidate),
|
|
any(character.isdigit() for character in candidate),
|
|
any(character in "+/_=-" for character in candidate),
|
|
)
|
|
)
|
|
return classes >= 3
|
|
|
|
|
|
def could_hold_secret(value: str) -> bool:
|
|
"""Report whether a raw value is long and opaque enough to be a credential."""
|
|
stripped = value.strip().strip("\"'")
|
|
if stripped.lower() in NON_SECRET_LITERALS or len(stripped) < MIN_SECRET_LENGTH:
|
|
return False
|
|
try:
|
|
float(stripped)
|
|
except ValueError:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _redact_assignment(match: re.Match[str]) -> str:
|
|
value = match.group("value")
|
|
if not is_sensitive_key(match.group("key")) or not could_hold_secret(value):
|
|
return match.group(0)
|
|
# Preserve the original quoting so screened JSON stays parsable JSON.
|
|
quoted = value.strip()[:1] in {'"', "'"}
|
|
replacement = f'"{REDACTED}"' if quoted else REDACTED
|
|
key_quote = match.group("quote")
|
|
return f"{key_quote}{match.group('key')}{key_quote}{match.group('gap')}{replacement}"
|
|
|
|
|
|
def _redact_entropy(match: re.Match[str]) -> str:
|
|
return REDACTED if _is_high_entropy(match.group(0)) else match.group(0)
|
|
|
|
|
|
def redact(text: str) -> str:
|
|
"""Return ``text`` with credential-shaped substrings replaced."""
|
|
if not text:
|
|
return text
|
|
for pattern in _PATTERNS:
|
|
text = pattern.sub(REDACTED, text)
|
|
text = _ASSIGNMENT_RE.sub(_redact_assignment, text)
|
|
return _ENTROPY_RE.sub(_redact_entropy, text)
|
|
|
|
|
|
def bound(text: str, max_bytes: int = DEFAULT_MAX_BYTES) -> tuple[str, bool]:
|
|
"""Truncate ``text`` to a UTF-8 byte budget, reporting whether it was cut."""
|
|
if max_bytes <= 0:
|
|
return ("", bool(text))
|
|
encoded = text.encode("utf-8", "replace")
|
|
if len(encoded) <= max_bytes:
|
|
return (text, False)
|
|
kept = encoded[:max_bytes].decode("utf-8", "ignore")
|
|
return (f"{kept}\n{TRUNCATION_NOTE}", True)
|
|
|
|
|
|
def safe_text(text: str, max_bytes: int = DEFAULT_MAX_BYTES) -> tuple[str, bool]:
|
|
"""Bound then redact, in that order, so a huge dump cannot outrun screening."""
|
|
bounded, truncated = bound(text, max_bytes)
|
|
return (redact(bounded), truncated)
|
|
|
|
|
|
def scrub(value: Any, max_bytes: int = DEFAULT_MAX_BYTES) -> Any:
|
|
"""Recursively bound and redact a JSON-shaped value.
|
|
|
|
Mapping keys are preserved (names are evidence); values under a sensitive
|
|
key are dropped outright rather than pattern-screened, because a short
|
|
secret would survive screening.
|
|
"""
|
|
if isinstance(value, str):
|
|
return safe_text(value, max_bytes)[0]
|
|
if isinstance(value, dict):
|
|
return {
|
|
str(key): REDACTED
|
|
if is_sensitive_key(str(key)) and could_hold_secret(str(item))
|
|
else scrub(item, max_bytes)
|
|
for key, item in value.items()
|
|
}
|
|
if isinstance(value, (list, tuple)):
|
|
return [scrub(item, max_bytes) for item in value]
|
|
return value
|
|
|
|
|
|
def contains_credential_shape(text: str) -> bool:
|
|
"""Report whether screening would rewrite ``text``.
|
|
|
|
Used by the harness self-test to prove a rendered report is clean before it
|
|
is written, and by the unit suite as an assertion helper.
|
|
"""
|
|
return redact(text) != text
|