fix(security): constrain SOPS writes and custody argument handoff
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
tegwick 2026-10-13 21:03:24 +02:00
parent b01a30bd49
commit 8f808577d6
8 changed files with 573 additions and 44 deletions

View file

@ -43,18 +43,11 @@ def unique_object(pairs):
return result
def age_document(source, recipient):
# Inspect ciphertext metadata before giving a credential to any subprocess.
# This repository's admitted SOPS profile is age-only, not arbitrary cloud
# KMS, key services, SSH identities or executable age plugins.
with source.open("rb") as encrypted:
raw = encrypted.read(8 * 1024 * 1024 + 1)
if len(raw) > 8 * 1024 * 1024:
raise Refused('encrypted input is too large')
if source.suffix == '.json':
def parse_document(raw, suffix):
if suffix == '.json':
data = json.loads(raw, object_pairs_hook=unique_object)
kind = 'json'
elif source.suffix in {'.yaml', '.yml'}:
elif suffix in {'.yaml', '.yml'}:
import yaml
class UniqueLoader(yaml.SafeLoader):
def construct_mapping(self, node, deep=False):
@ -69,6 +62,19 @@ def age_document(source, recipient):
kind = 'yaml'
else:
raise Refused('age recovery supports SOPS JSON/YAML input')
return data, kind
def age_document(source, recipient, *, raw=None):
# Inspect ciphertext metadata before giving a credential to any subprocess.
# This repository's admitted SOPS profile is age-only, not arbitrary cloud
# KMS, key services, SSH identities or executable age plugins.
if raw is None:
with source.open("rb") as encrypted:
raw = encrypted.read(8 * 1024 * 1024 + 1)
if len(raw) > 8 * 1024 * 1024:
raise Refused('encrypted input is too large')
data, kind = parse_document(raw, source.suffix)
metadata = data.get('sops') if isinstance(data, dict) else None
if not isinstance(metadata, dict):
raise Refused('SOPS metadata is missing')
@ -96,9 +102,12 @@ def private_key_fd():
name = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FILE', '')
if not name:
raise Refused('explicit temporary custody key required')
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW)
path = Path(os.path.abspath(name))
if any(p.is_symlink() for p in (path, *path.parents)):
raise Refused('custody key path must not contain symlinks')
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_size > 16384:
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > 16384:
os.close(fd)
raise Refused('custody key must be an owned private regular file')
return fd