fix(egress): address maxpetrusenko P1/P2 review — fail-closed secrets, NODE_OPTIONS conflict, GPG verify, threat-model scope

Follow-up to the #30179 security review (maxpetrusenko). P0s 1-3 are being
handled separately (kuangmi-bit); this covers the three P1s + the P2.

P1 #4 — secrets replace rules now emit `require: true`. Verified
replaceConfig.Require EXISTS in the pinned iron-proxy v0.39.0 secrets
transform (KnownFields(true) strict decode would otherwise reject it) and is
enforced in TransformRequest: a request to an allowlisted upstream that
arrives WITHOUT the proxy token in a matched location is rejected
(ActionReject) rather than forwarded with whatever credential it carried.
Closes the leak where a real provider key sent directly to an allowed host
passed the proxy boundary.

P1 #5 — NODE_OPTIONS append-merge now resolves CA-mode conflicts. A
docker_env `--use-bundled-ca` would previously survive alongside the
egress-required `--use-openssl-ca`, leaving Node's trust behavior dependent
on option order. Egress flag now wins deterministically (conflicting CA-mode
flags stripped + warning); unrelated operator tuning preserved.

P1 #6 — install now GPG-verifies the release. checksums.txt is verified
against checksums.txt.asc using the bundled public-key.asc in an ephemeral
keyring. Best-effort: degrades with a warning when gpg/sig assets are
unavailable (SHA-256 still enforced); a PRESENT-but-invalid signature is a
tamper signal and hard-fails the install.

P2 #7 — threat-model wording scoped to the 'configured trusted proxy
boundary' across the module docstring, config.py, and the egress docs, with
a new security-model bullet on CA-key / endpoint-integrity loss (MITRE
T1588.004 AiTM).

Tests: +2 NODE_OPTIONS conflict/preserve, +5 GPG verify (skip/missing/bad/
good/install-abort), +1 require assertion. iron_proxy 94 + docker 74 green; ruff clean.
This commit is contained in:
Bartok9 2026-06-17 18:24:39 -04:00 • committed by Teknium
parent a697b4b86a
commit 45da4b2363
No known key found for this signature in database
6 changed files with 259 additions and 10 deletions

View file

@ -12,7 +12,10 @@ iron-proxy is a TLS-intercepting egress firewall (Apache-2.0, Go binary, by
ironsh). It sits between the sandbox and the internet, enforces a default-deny
allowlist on outbound hosts, and *swaps proxy tokens for real credentials*
on the way out. The sandbox only ever holds opaque proxy tokens — leaking
them is useless, since they only work from behind the proxy.
them is useless, since they only work behind the configured trusted proxy
boundary (the CA private key and proxy endpoint integrity are part of that
boundary: if traffic can be redirected to attacker-controlled proxy
infrastructure, the guarantee no longer holds).
Design summary
--------------
@ -90,6 +93,12 @@ _IRON_PROXY_RELEASE_BASE = (
f"https://github.com/ironsh/iron-proxy/releases/download/v{_IRON_PROXY_VERSION}"
)
_IRON_PROXY_CHECKSUM_NAME = "checksums.txt"
# Detached signature for checksums.txt + the signing public key, both shipped on
# the release. Used for optional GPG verification of the release channel
# (maxpetrusenko P1): SHA-256 only protects the archive if checksums.txt itself
# came from an uncompromised channel; verifying its signature closes that gap.
_IRON_PROXY_CHECKSUM_SIG_NAME = "checksums.txt.asc"
_IRON_PROXY_PUBKEY_NAME = "public-key.asc"
# How long to wait for HTTP downloads and subprocess interactions, in seconds.
_DOWNLOAD_TIMEOUT = 120 # binary is ~16MB
@ -436,6 +445,15 @@ def install_iron_proxy(*, force: bool = False) -> Path:
_http_download(asset_url, archive_path)
_http_download(checksum_url, checksum_path)
# Defense-in-depth (maxpetrusenko P1): verify the GPG signature of
# checksums.txt before trusting it. The archive download honors ambient
# proxy env (urllib), so a compromised channel could serve a matching
# binary + checksums pair; the detached signature + pinned public key
# close that release-channel tamper gap. Best-effort: if gpg or the
# signature assets aren't available we log and fall back to the SHA-256
# check alone rather than hard-failing offline installs.
_verify_checksums_signature(tmp, checksum_path)
expected = _expected_sha256(checksum_path, asset_name)
actual = _sha256_file(archive_path)
if expected.lower() != actual.lower():
@ -493,6 +511,79 @@ def _http_download(url: str, dest: Path) -> None:
raise RuntimeError(f"Failed to download {url}: {exc}") from exc
def _verify_checksums_signature(tmp: Path, checksum_path: Path) -> bool:
"""Best-effort GPG verification of ``checksums.txt`` (maxpetrusenko P1).
Downloads the detached signature (``checksums.txt.asc``) and the release
signing key (``public-key.asc``), imports the key into an ephemeral
keyring, and verifies the signature over ``checksum_path``.
Returns True when the signature is verified. Returns False (with a warning)
when verification is unavailable — ``gpg`` not installed, or the signature /
public-key assets are missing from the release. Raises RuntimeError ONLY
when verification actively FAILS (a present-but-bad signature), which is a
tamper signal we must not ignore.
Rationale for graceful degradation on "unavailable": the SHA-256 check
against ``checksums.txt`` remains in force regardless, and many install
hosts (CI, minimal containers) won't have gpg. We harden when we can and
never make gpg a hard dependency for a working install.
"""
gpg = shutil.which("gpg")
if not gpg:
logger.warning(
"gpg not found on PATH — skipping iron-proxy release-signature "
"verification (SHA-256 checksum check still enforced)."
)
return False
sig_url = f"{_IRON_PROXY_RELEASE_BASE}/{_IRON_PROXY_CHECKSUM_SIG_NAME}"
pubkey_url = f"{_IRON_PROXY_RELEASE_BASE}/{_IRON_PROXY_PUBKEY_NAME}"
sig_path = tmp / _IRON_PROXY_CHECKSUM_SIG_NAME
pubkey_path = tmp / _IRON_PROXY_PUBKEY_NAME
try:
_http_download(sig_url, sig_path)
_http_download(pubkey_url, pubkey_path)
except RuntimeError as exc:
logger.warning(
"iron-proxy release signature assets unavailable (%s) — skipping "
"GPG verification (SHA-256 checksum check still enforced).", exc,
)
return False
# Ephemeral keyring so we never touch the user's real GPG home.
gnupg_home = tmp / "gnupg"
gnupg_home.mkdir(mode=0o700, exist_ok=True)
base_cmd = [gpg, "--homedir", str(gnupg_home), "--batch", "--no-tty"]
imp = subprocess.run( # noqa: S603 — gpg path from trusted PATH lookup
[*base_cmd, "--import", str(pubkey_path)],
capture_output=True, timeout=60,
)
if imp.returncode != 0:
logger.warning(
"Could not import iron-proxy signing key — skipping GPG "
"verification (SHA-256 still enforced): %s",
imp.stderr.decode("utf-8", "replace")[:200],
)
return False
verify = subprocess.run( # noqa: S603
[*base_cmd, "--verify", str(sig_path), str(checksum_path)],
capture_output=True, timeout=60,
)
if verify.returncode != 0:
# A present signature that does NOT verify is a tamper signal — fail hard.
raise RuntimeError(
"iron-proxy checksums.txt failed GPG signature verification — "
"refusing to install (possible release-channel tampering). "
f"gpg: {verify.stderr.decode('utf-8', 'replace')[:300]}"
)
logger.info("Verified iron-proxy checksums.txt GPG signature.")
return True
def _expected_sha256(checksum_file: Path, asset_name: str) -> str:
"""Parse the standard ``sha256sum`` output: ``<hex> <filename>``."""
@ -874,6 +965,16 @@ def build_proxy_config(
# don't want body inspection forced for every request.
"match_query": True,
"match_body": False,
# Fail closed (maxpetrusenko P1): when a request reaches an
# allowlisted upstream WITHOUT the proxy token present in a
# matched location, reject it instead of forwarding as-is.
# Without this, a real provider key that a sandbox process
# sent directly (not via the minted token) would still pass
# the proxy boundary to the allowed host. With require=true,
# iron-proxy returns ActionReject when no token swap fired
# (v0.39 secrets transform: replaceConfig.Require, enforced in
# TransformRequest — verified present in the pinned version).
"require": True,
},
"rules": [{"host": h} for h in m.upstream_hosts],
})

View file

@ -2545,7 +2545,8 @@ DEFAULT_CONFIG = {
# today; Modal/SSH in follow-ups) is routed through a managed iron-proxy
# subprocess. The sandbox sees opaque proxy tokens; iron-proxy swaps in
# real API credentials at the egress boundary. Compromising the sandbox
# leaks tokens that only work from behind the proxy.
# leaks tokens that only work behind the configured trusted proxy boundary
# (CA private key + proxy endpoint integrity are part of that boundary).
#
# Configure with `hermes egress setup`. Disabled by default — the rest of
# Hermes works exactly as before with `enabled: false`.

View file

@ -125,6 +125,10 @@ def test_build_proxy_config_shape(tmp_path):
# The proxy token is the replacement target.
assert rule["replace"]["proxy_value"] == m.proxy_token
assert "Authorization" in rule["replace"]["match_headers"]
# Fail-closed: a request to a mapped host without the proxy token must be
# rejected, not forwarded with whatever credential it carried
# (maxpetrusenko P1). iron-proxy's replaceConfig.Require enforces this.
assert rule["replace"]["require"] is True
# Rules list contains one entry per upstream host.
rule_hosts = {r["host"] for r in rule["rules"]}
assert rule_hosts == set(m.upstream_hosts)
@ -566,6 +570,86 @@ def test_install_iron_proxy_rejects_missing_checksum_entry(hermes_home, monkeypa
ip.install_iron_proxy()
# ── GPG release-signature verification (maxpetrusenko P1) ────────────────────
def test_verify_checksums_signature_skips_without_gpg(hermes_home, monkeypatch, tmp_path):
"""No gpg on PATH → degrade gracefully (return False), do not raise."""
monkeypatch.setattr(ip.shutil, "which", lambda name: None)
cks = tmp_path / "checksums.txt"
cks.write_text("abc iron-proxy.tar.gz\n")
assert ip._verify_checksums_signature(tmp_path, cks) is False
def test_verify_checksums_signature_skips_when_sig_assets_missing(hermes_home, monkeypatch, tmp_path):
"""gpg present but the release ships no .asc assets → degrade, don't raise."""
monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None)
def fail_download(url: str, dest: Path) -> None:
raise RuntimeError("404 not found")
monkeypatch.setattr(ip, "_http_download", fail_download)
cks = tmp_path / "checksums.txt"
cks.write_text("abc iron-proxy.tar.gz\n")
assert ip._verify_checksums_signature(tmp_path, cks) is False
def test_verify_checksums_signature_raises_on_bad_signature(hermes_home, monkeypatch, tmp_path):
"""A present-but-INVALID signature is a tamper signal → must raise."""
monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None)
monkeypatch.setattr(ip, "_http_download", lambda url, dest: dest.write_bytes(b"asc"))
class _R:
def __init__(self, rc): self.returncode = rc; self.stderr = b"BAD signature"
def fake_run(cmd, **kw):
# import succeeds (rc 0), verify fails (rc 1)
return _R(0) if "--import" in cmd else _R(1)
monkeypatch.setattr(ip.subprocess, "run", fake_run)
cks = tmp_path / "checksums.txt"
cks.write_text("abc iron-proxy.tar.gz\n")
with pytest.raises(RuntimeError, match="failed GPG signature verification"):
ip._verify_checksums_signature(tmp_path, cks)
def test_verify_checksums_signature_passes_on_good_signature(hermes_home, monkeypatch, tmp_path):
"""Valid signature → returns True."""
monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None)
monkeypatch.setattr(ip, "_http_download", lambda url, dest: dest.write_bytes(b"asc"))
class _R:
def __init__(self, rc): self.returncode = rc; self.stderr = b""
monkeypatch.setattr(ip.subprocess, "run", lambda cmd, **kw: _R(0))
cks = tmp_path / "checksums.txt"
cks.write_text("abc iron-proxy.tar.gz\n")
assert ip._verify_checksums_signature(tmp_path, cks) is True
def test_install_aborts_on_bad_release_signature(hermes_home, monkeypatch):
"""End-to-end: a tampered (bad-signature) release must abort install."""
fake_payload = _make_fake_tar(ip._platform_binary_name())
import hashlib
sha = hashlib.sha256(fake_payload).hexdigest()
asset_name = ip._platform_asset_name()
def fake_download(url: str, dest: Path) -> None:
if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME):
dest.write_text(f"{sha} {asset_name}\n")
elif url.endswith(".asc"):
dest.write_bytes(b"-----BEGIN PGP-----\n")
else:
dest.write_bytes(fake_payload)
monkeypatch.setattr(ip, "_http_download", fake_download)
monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None)
class _R:
def __init__(self, rc): self.returncode = rc; self.stderr = b"BAD"
monkeypatch.setattr(ip.subprocess, "run",
lambda cmd, **kw: _R(0) if "--import" in cmd else _R(1))
with pytest.raises(RuntimeError, match="GPG signature verification"):
ip.install_iron_proxy()
def test_pick_tar_member_rejects_path_traversal():
"""A malicious tar that escapes via '..' must be refused."""

View file

@ -358,6 +358,52 @@ def test_docker_env_appears_in_run_command(monkeypatch):
assert "GNUPGHOME=/root/.gnupg" in run_args_str
def _node_options_from_run(calls):
run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"]
assert run_calls, "docker run should have been called"
args = run_calls[0][0]
for i, a in enumerate(args):
if a == "-e" and i + 1 < len(args) and args[i + 1].startswith("NODE_OPTIONS="):
return args[i + 1].split("=", 1)[1]
return None
def test_egress_node_options_overrides_conflicting_ca_flag(monkeypatch):
"""maxpetrusenko P1: a conflicting docker_env NODE_OPTIONS CA-mode flag
(--use-bundled-ca) must be replaced by the egress-required --use-openssl-ca,
not left to survive alongside it (final Node trust would depend on order)."""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(
docker_env, "_egress_proxy_args_for_docker",
lambda: ([], {"_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca"}, []),
)
calls = _mock_subprocess_run(monkeypatch)
_make_dummy_env(env={"NODE_OPTIONS": "--max-old-space-size=8192 --use-bundled-ca"})
node_opts = (_node_options_from_run(calls) or "").split()
assert "--use-openssl-ca" in node_opts, "egress CA flag must be present"
assert "--use-bundled-ca" not in node_opts, "conflicting CA flag must be stripped"
# Operator's unrelated tuning must be preserved.
assert "--max-old-space-size=8192" in node_opts
def test_egress_node_options_preserves_operator_tuning(monkeypatch):
"""Non-conflicting operator NODE_OPTIONS survive the egress append-merge."""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(
docker_env, "_egress_proxy_args_for_docker",
lambda: ([], {"_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca"}, []),
)
calls = _mock_subprocess_run(monkeypatch)
_make_dummy_env(env={"NODE_OPTIONS": "--max-old-space-size=4096"})
node_opts = (_node_options_from_run(calls) or "").split()
assert "--use-openssl-ca" in node_opts
assert "--max-old-space-size=4096" in node_opts
def test_docker_env_appears_in_init_env_args(monkeypatch):
"""Explicit docker_env values should appear in _build_init_env_args."""
env = _make_execute_only_env()

View file

@ -1114,15 +1114,31 @@ class DockerEnvironment(BaseEnvironment):
)
if _egress_node_append:
existing_node = merged_env.get("NODE_OPTIONS", "")
existing_tokens = existing_node.split()
# maxpetrusenko P1: dedupe is not enough — the operator may have set
# a CONFLICTING CA-mode flag (e.g. --use-bundled-ca) that would
# otherwise survive alongside our --use-openssl-ca, leaving Node's
# final trust behavior dependent on option order / Node parsing.
# Egress isolation requires our flag to win deterministically, so
# strip any known-conflicting CA-mode flags before appending.
_CA_MODE_FLAGS = {"--use-openssl-ca", "--use-bundled-ca"}
append_token = _egress_node_append.strip()
if append_token in _CA_MODE_FLAGS:
dropped = [t for t in existing_tokens if t in _CA_MODE_FLAGS and t != append_token]
if dropped:
logger.warning(
"Overriding conflicting NODE_OPTIONS CA-mode flag(s) %s "
"with egress-required %s to keep Node routed through the "
"egress CA store.", dropped, append_token,
)
existing_tokens = [t for t in existing_tokens if t not in _CA_MODE_FLAGS or t == append_token]
# De-dup: only add if not already present (the operator may
# have set the same flag themselves).
if _egress_node_append.strip() not in existing_node.split():
if existing_node.strip():
merged_env["NODE_OPTIONS"] = (
f"{existing_node} {_egress_node_append}".strip()
)
else:
merged_env["NODE_OPTIONS"] = _egress_node_append
if append_token not in existing_tokens:
existing_tokens.append(append_token)
merged_env["NODE_OPTIONS"] = " ".join(existing_tokens).strip()
if not merged_env["NODE_OPTIONS"]:
merged_env.pop("NODE_OPTIONS", None)
env_args = []
for key in sorted(merged_env):

View file

@ -2,7 +2,7 @@
When Hermes runs your agent inside a Docker terminal sandbox, that sandbox normally holds your real upstream API keys (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, etc.). A prompt-injected agent in that sandbox can `cat ~/.config/openrouter/auth.json` or `printenv | grep -i key` and exfiltrate them.
The egress proxy fixes this: the sandbox holds opaque **proxy tokens**, never the real keys. All outbound traffic from the sandbox routes through a local [iron-proxy](https://github.com/ironsh/iron-proxy) daemon (Apache-2.0, Go) on the host, which terminates TLS and swaps the proxy token for the real credential before forwarding the request upstream. Compromise the sandbox and the attacker walks away with tokens that only work from behind the proxy.
The egress proxy fixes this: the sandbox holds opaque **proxy tokens**, never the real keys. All outbound traffic from the sandbox routes through a local [iron-proxy](https://github.com/ironsh/iron-proxy) daemon (Apache-2.0, Go) on the host, which terminates TLS and swaps the proxy token for the real credential before forwarding the request upstream. Compromise the sandbox and the attacker walks away with tokens that only work behind the **configured trusted proxy boundary** — the CA private key and the proxy endpoint integrity are part of that boundary. If traffic can be redirected to attacker-controlled proxy infrastructure (e.g. a stolen CA private key or a hijacked proxy endpoint), the token guarantee no longer holds.
This release wires the egress proxy into the Docker backend only. Modal, Daytona, SSH, and Singularity do **not** receive proxy env vars or CA mounts yet.
@ -437,6 +437,7 @@ If the nonce check fails, the code falls back to matching `argv[0]` basename aga
**What it does NOT protect against:**
- A compromised host process. If the agent process itself is compromised, real keys in the host's `~/.hermes/.env` are exposed regardless. This is a defense-in-depth feature for *sandbox* compromise, not host compromise.
- **Loss of the trusted-proxy boundary itself.** The token-swap guarantee assumes the sandbox trusts the mounted CA cert (`/etc/ssl/certs/hermes-egress-ca.crt`) and that traffic actually reaches *our* iron-proxy. If the CA private key is stolen, or sandbox egress is redirected to attacker-controlled proxy infrastructure, an adversary-in-the-middle can present a valid leaf cert and the proxy tokens are no longer a meaningful boundary (cf. [MITRE ATT&CK T1588.004](https://attack.mitre.org/techniques/T1588/004/) — obtained TLS certificate material enabling AiTM). Protect the CA key (it's `0600`, host-only) and the proxy endpoint accordingly.
- Sandbox processes that bypass `HTTPS_PROXY` by using a raw socket. The proxy can't intercept what doesn't route to it. Node.js is partially mitigated via `NODE_OPTIONS=--use-openssl-ca` (see caveat above).
- Credential files explicitly mounted into Docker (`terminal.credential_files` or skill-registered mounts). Egress protects provider env vars; it does not inspect arbitrary mounted files. Do not mount real provider credentials into an enforced egress sandbox.
- Allowlisted-host data exfiltration. If `api.openai.com` is allowed, an agent could embed exfil data in a request body to that host. The daemon log captures the request happened but doesn't prevent it.