fix(security): constrain SOPS writes and custody argument handoff
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
parent
b01a30bd49
commit
8f808577d6
8 changed files with 573 additions and 44 deletions
|
|
@ -2,14 +2,14 @@
|
|||
# Any file under a secrets/ directory (at any depth) is encrypted with the operator age key.
|
||||
# Same age keypair as railiance-infra — one key per operator across all repos.
|
||||
#
|
||||
# Key fingerprint: age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4
|
||||
# Public key stored in: keys/age.pub
|
||||
#
|
||||
# To edit an encrypted file: sops secrets/<file>
|
||||
# To encrypt a new file: sops --encrypt --in-place secrets/<file>
|
||||
# To replace encrypted content: make sops-edit FILE=secrets/<file>.yaml INPUT=<private-directory>/<replacement>.yaml
|
||||
# To encrypt a private untracked file: make sops-encrypt FILE=secrets/<file>.yaml
|
||||
# Attended private recovery: make sops-decrypt FILE=secrets/<file>.yaml OUTPUT=<private-directory>/<new-file>
|
||||
# Run through the custody unlock helper; never decrypt into a transcript.
|
||||
# To add a recipient: update .sops.yaml + sops --rotate --in-place secrets/<file>
|
||||
# To rotate the data key only: make sops-rotate FILE=secrets/<file>.yaml
|
||||
# Recipient changes require a separately reviewed custody migration; rotate does not apply this config.
|
||||
|
||||
creation_rules:
|
||||
- path_regex: secrets/.*$
|
||||
|
|
|
|||
46
Makefile
46
Makefile
|
|
@ -53,39 +53,39 @@ hooks-test: ## Test that the pre-commit hook blocks plaintext secrets
|
|||
sops-setup: ## Validate the registered public age recipient; never install a private key
|
||||
@python3 sso-mfa/bootstrap/sops-private.py setup
|
||||
|
||||
sops-edit: ## Edit an encrypted file with SOPS: make sops-edit FILE=secrets/foo.yaml
|
||||
@[[ -n "$(FILE)" ]] || (echo "Usage: make sops-edit FILE=secrets/path/to/file.yaml"; exit 1)
|
||||
sops $(FILE)
|
||||
# Raw values prevent both Make-function evaluation and shell interpolation.
|
||||
unexport FILE INPUT OUTPUT COMMAND OPERATOR_AGE_PUBKEY
|
||||
sops-edit sops-encrypt sops-decrypt sops-rotate: export NK_SOPS_FILE := $(value FILE)
|
||||
sops-edit: export NK_SOPS_INPUT := $(value INPUT)
|
||||
sops-decrypt: export NK_SOPS_OUTPUT := $(value OUTPUT)
|
||||
|
||||
sops-encrypt: ## Encrypt a file in place: make sops-encrypt FILE=secrets/foo.yaml
|
||||
@[[ -n "$(FILE)" ]] || (echo "Usage: make sops-encrypt FILE=secrets/path/to/file.yaml"; exit 1)
|
||||
sops --encrypt --in-place $(FILE)
|
||||
@echo "✔ Encrypted $(FILE)"
|
||||
sops-edit: ## Replace encrypted content from private INPUT outside Git; explicit custody required
|
||||
@python3 sso-mfa/bootstrap/sops-write.py edit
|
||||
|
||||
sops-encrypt: ## Encrypt private, untracked FILE in place with the registered public recipient
|
||||
@python3 sso-mfa/bootstrap/sops-write.py encrypt
|
||||
|
||||
sops-decrypt: export NK_SOPS_FILE = $(FILE)
|
||||
sops-decrypt: export NK_SOPS_OUTPUT = $(OUTPUT)
|
||||
sops-decrypt: ## Decrypt age-only JSON/YAML to new private OUTPUT outside Git; explicit custody required
|
||||
@python3 sso-mfa/bootstrap/sops-private.py decrypt
|
||||
|
||||
sops-rotate: ## Rotate SOPS recipients after updating .sops.yaml: make sops-rotate FILE=secrets/foo.yaml
|
||||
@[[ -n "$(FILE)" ]] || (echo "Usage: make sops-rotate FILE=secrets/path/to/file.yaml"; exit 1)
|
||||
sops --rotate --in-place $(FILE)
|
||||
@echo "✔ Recipients rotated for $(FILE)"
|
||||
sops-rotate: ## Rotate the SOPS data key; recipient changes require a separate reviewed procedure
|
||||
@python3 sso-mfa/bootstrap/sops-write.py rotate
|
||||
|
||||
sops-custody-check: ## Validate a custody age key against keys/age.pub without keeping it on disk
|
||||
sops-custody-check sops-custody-shell sops-custody-run: export NK_CUSTODY_RECIPIENT := $(value OPERATOR_AGE_PUBKEY)
|
||||
sops-custody-run: export NK_CUSTODY_COMMAND := $(value COMMAND)
|
||||
|
||||
sops-custody-check: ## Validate a custody age key without keeping it on disk
|
||||
@bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||
--expected-recipient "$(OPERATOR_AGE_PUBKEY)" \
|
||||
--check-only
|
||||
--expected-recipient "$$NK_CUSTODY_RECIPIENT" --check-only
|
||||
|
||||
sops-custody-shell: ## Open a shell with a temporary SOPS_AGE_KEY_FILE from custody material
|
||||
@bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||
--expected-recipient "$(OPERATOR_AGE_PUBKEY)"
|
||||
--expected-recipient "$$NK_CUSTODY_RECIPIENT"
|
||||
|
||||
sops-custody-run: ## Run COMMAND with a temporary custody age key: make sops-custody-run COMMAND='make -C ../inter-hub recovery-drill'
|
||||
@[[ -n "$(COMMAND)" ]] || (echo "Usage: make sops-custody-run COMMAND='make -C ../inter-hub recovery-drill'"; exit 1)
|
||||
@COMMAND='$(COMMAND)' bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||
--expected-recipient "$(OPERATOR_AGE_PUBKEY)" \
|
||||
-- bash -lc "$$COMMAND"
|
||||
sops-custody-run: ## Run COMMAND inside the temporary custody handoff
|
||||
@[[ -n "$$NK_CUSTODY_COMMAND" ]] || (echo "Usage: make sops-custody-run COMMAND='<approved command>'"; exit 1)
|
||||
@bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||
--expected-recipient "$$NK_CUSTODY_RECIPIENT" -- bash -lc "$$NK_CUSTODY_COMMAND"
|
||||
|
||||
check-secrets: ## Fail if any file under secrets/ is not SOPS-encrypted
|
||||
@echo "Checking for unencrypted files under secrets/..."
|
||||
|
|
@ -100,7 +100,7 @@ check-secrets: ## Fail if any file under secrets/ is not SOPS-encrypted
|
|||
done < <(git ls-files --others --cached 'secrets' 2>/dev/null | grep -v '/$$'); \
|
||||
if [[ "$$bad" -ne 0 ]]; then \
|
||||
echo ""; \
|
||||
echo "ERROR: Unencrypted secret(s) detected. Encrypt with: sops --encrypt --in-place <file>"; \
|
||||
echo "ERROR: Unencrypted file detected. For private untracked input: make sops-encrypt FILE=<file>; tracked plaintext requires incident review."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "✔ All secrets/ files appear SOPS-encrypted"
|
||||
|
|
|
|||
|
|
@ -174,10 +174,10 @@ its trap while a foreground child runs; this is not a process-group supervisor,
|
|||
SIGKILL cleanup guarantee or a runtime authority boundary. Temporary key cleanup
|
||||
on command return does not certify that an arbitrary child retained no copies.
|
||||
|
||||
Remaining concrete caller defects stay in NK-WP-0049-T02: legacy rotation and
|
||||
privacyIDEA bootstrap helpers contain secret-bearing output; direct editor,
|
||||
rotate and delegated apply/init paths still need their own custody/output audit. Do not use those legacy paths as the recovery procedure
|
||||
or treat these narrower helper repairs as closure of the whole custody surface.
|
||||
Remaining concrete caller defects stay in NK-WP-0049-T02, including legacy
|
||||
diagnostics, key export and coordinated rotation. Its current checklist records
|
||||
repaired callers and outstanding work. Do not treat narrower helper repairs as
|
||||
closure of the whole custody surface.
|
||||
|
||||
## Make recovery entry points — 2026-10-12
|
||||
|
||||
|
|
@ -226,6 +226,45 @@ loads explicit and ambient identities together, so setting `SOPS_AGE_KEY_FILE`
|
|||
alone is insufficient to exclude ambient credentials.
|
||||
|
||||
|
||||
## SOPS writes — 2026-10-13
|
||||
|
||||
The Make wrappers support the registered single-age JSON/YAML profile:
|
||||
|
||||
- `make sops-encrypt FILE=...` encrypts a private, owned, untracked plaintext
|
||||
mapping in place using only `keys/age.pub`. It needs no private key. Tracked
|
||||
plaintext is refused and needs incident review; encryption cannot erase history.
|
||||
- `make sops-edit FILE=... INPUT=...` replaces content from an owned private
|
||||
plaintext file in a private directory outside Git. Use private decryption above
|
||||
to prepare that file, edit it in the approved custody environment, then remove
|
||||
it after successful replacement. No ambient editor is launched. INPUT must use
|
||||
the same JSON/YAML format. The original MAC is verified before replacement and
|
||||
its declared field-encryption policy is preserved.
|
||||
- `make sops-rotate FILE=...` rotates the data key and re-encrypts values. It does
|
||||
**not** migrate recipients or apply changes to `.sops.yaml`. A recipient change
|
||||
needs its own reviewed custody migration, independent recipient admission and
|
||||
recovery verification.
|
||||
|
||||
Edit and rotate require the explicit matching temporary custody key. Both refuse
|
||||
extra document recipients, even when the registered key can decrypt the file:
|
||||
metadata is not authority to rewrap a data key for another recipient. The new
|
||||
ciphertext is decrypted for verification with plaintext output discarded.
|
||||
Inputs reject symlinks, hardlinks, nonregular files and unsafe modes; key inputs
|
||||
also reject symlink ancestors and FIFOs. Parser and tool diagnostics are captured.
|
||||
Make passes FILE, INPUT, OUTPUT and custody arguments literally, without evaluating
|
||||
Make functions or interpolating them into shell code before the custody handoff.
|
||||
The approved COMMAND is still intentionally executed inside that handoff.
|
||||
|
||||
Ciphertext publication uses private staging, a cooperative file lock and a final
|
||||
source identity/version check followed by atomic rename. The check and rename are
|
||||
separate operations: this is not protection against hostile concurrent same-UID
|
||||
writers or ancestor-directory substitution. Directory fsync failure after rename
|
||||
can report failure after publication; inspect state before retrying. SIGKILL may
|
||||
leave staging files. Private modes and a temporary key are not credential isolation
|
||||
or an authorization grant. Current native recovery acceptance remains NK-WP-0049-T03.
|
||||
|
||||
Version-specific behavior was exercised with disposable SOPS 3.13.3 and age keys;
|
||||
see the [upstream SOPS documentation](https://github.com/getsops/sops/blob/v3.13.3/README.rst).
|
||||
|
||||
## Rotation output boundary (2026-10-12 source repair)
|
||||
|
||||
`creds-rotate.sh` keeps generated values out of stdout/stderr and disables shell
|
||||
|
|
|
|||
|
|
@ -208,3 +208,14 @@ make sops-custody-shell
|
|||
```
|
||||
|
||||
Exit the shell when finished so the temporary key file is removed.
|
||||
|
||||
|
||||
For repository SOPS files, use `make sops-encrypt FILE=...` with private untracked
|
||||
JSON/YAML plaintext. Inside the approved custody handoff, `make sops-decrypt
|
||||
FILE=... OUTPUT=...` creates a new private file outside Git, and `make sops-edit
|
||||
FILE=... INPUT=...` replaces encrypted content from a private file outside Git.
|
||||
Remove recovered/replacement plaintext after use. `make sops-rotate FILE=...`
|
||||
rotates only the data key; recipient migration requires a separately reviewed
|
||||
procedure. Edit/rotate require the explicit matching key and exactly the registered
|
||||
recipient. See [the custody contract](../../docs/security-bootstrap-age-custody.md)
|
||||
for input restrictions, failure handling and concurrency limits.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
188
sso-mfa/bootstrap/sops-write.py
Normal file
188
sso-mfa/bootstrap/sops-write.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Private SOPS writes for the independently registered single-age profile.
|
||||
|
||||
Edit consumes an explicitly prepared private plaintext file. No editor or shell
|
||||
is launched, and subprocess plaintext output is discarded, never forwarded.
|
||||
"""
|
||||
import contextlib
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
spec = importlib.util.spec_from_file_location('sops_private', Path(__file__).with_name('sops-private.py'))
|
||||
base = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(base)
|
||||
Refused = base.Refused
|
||||
LIMIT = 8 * 1024 * 1024
|
||||
POLICY = ('unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex', 'encrypted_regex',
|
||||
'unencrypted_comment_regex', 'encrypted_comment_regex')
|
||||
|
||||
|
||||
def in_git(parent):
|
||||
return any((p / '.git').is_file() or (p / '.git/HEAD').is_file()
|
||||
or ((p / 'HEAD').is_file() and (p / 'objects').is_dir() and (p / 'refs').is_dir())
|
||||
for p in (parent, *parent.parents))
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def snapshot(name, *, plaintext=False, replacement=False, lock=False):
|
||||
if not name: raise Refused('explicit FILE or INPUT is required')
|
||||
path = Path(os.path.abspath(name))
|
||||
if any(p.is_symlink() for p in (path, *path.parents)):
|
||||
raise Refused('file paths must not contain symlinks')
|
||||
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
fd = None
|
||||
try:
|
||||
parent = os.fstat(directory)
|
||||
if parent.st_uid != os.getuid() or parent.st_mode & (0o077 if replacement else 0o022):
|
||||
raise Refused('file directory ownership/mode refused')
|
||||
if replacement and in_git(path.parent):
|
||||
raise Refused('private replacement input must be outside Git')
|
||||
fd = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
|
||||
info = os.fstat(fd)
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1
|
||||
or info.st_mode & (0o077 if plaintext else 0o022)):
|
||||
raise Refused('file ownership/type/mode refused')
|
||||
if lock: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
with os.fdopen(os.dup(fd), 'rb') as stream:
|
||||
raw = stream.read(LIMIT + 1)
|
||||
if len(raw) > LIMIT: raise Refused('input exceeds the supported size')
|
||||
yield path, directory, info, raw
|
||||
finally:
|
||||
if fd is not None: os.close(fd)
|
||||
os.close(directory)
|
||||
|
||||
|
||||
def plain_document(raw, suffix):
|
||||
data, kind = base.parse_document(raw, suffix)
|
||||
if not isinstance(data, dict) or not data or 'sops' in data:
|
||||
raise Refused('plaintext must be a nonempty JSON/YAML mapping without SOPS metadata')
|
||||
return kind
|
||||
|
||||
|
||||
def encrypted_document(raw, path, recipient):
|
||||
_, kind = base.age_document(path, recipient, raw=raw)
|
||||
data, _ = base.parse_document(raw, path.suffix)
|
||||
metadata = data['sops']
|
||||
# Document recipient metadata is not an independent admission of another
|
||||
# recipient. Never rewrap a data key to an injected or unreviewed identity.
|
||||
if len(metadata['age']) != 1 or metadata['age'][0]['recipient'] != recipient:
|
||||
raise Refused('write requires exactly the independently registered recipient')
|
||||
return kind, metadata
|
||||
|
||||
|
||||
def sops(binary, arguments, raw, env, isolated, keyfd=None, *, discard=False):
|
||||
result = subprocess.run([binary, *arguments, '/dev/stdin'], input=raw,
|
||||
stdout=subprocess.DEVNULL if discard else subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, cwd=isolated, env=env,
|
||||
pass_fds=() if keyfd is None else (keyfd,), timeout=30)
|
||||
if result.returncode: raise Refused('SOPS operation failed; no replacement published')
|
||||
if not discard and (not result.stdout or len(result.stdout) > LIMIT):
|
||||
raise Refused('SOPS output was empty or oversized')
|
||||
return None if discard else result.stdout
|
||||
|
||||
|
||||
def publish(path, directory, original, encrypted):
|
||||
temporary = '.netkingdom-sops-write-' + uuid.uuid4().hex
|
||||
try:
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
|
||||
with os.fdopen(fd, 'wb') as file:
|
||||
file.write(encrypted); file.flush(); os.fsync(file.fileno())
|
||||
current = os.stat(path.name, dir_fd=directory, follow_symlinks=False)
|
||||
fields = ('st_dev', 'st_ino', 'st_size', 'st_mtime_ns', 'st_ctime_ns')
|
||||
if any(getattr(current, key) != getattr(original, key) for key in fields):
|
||||
raise Refused('source changed during operation; replacement refused')
|
||||
os.replace(temporary, path.name, src_dir_fd=directory, dst_dir_fd=directory)
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
try: os.unlink(temporary, dir_fd=directory)
|
||||
except FileNotFoundError: pass
|
||||
|
||||
|
||||
def write(action, recipient):
|
||||
binary = shutil.which('sops')
|
||||
if binary is None: raise Refused('sops executable is unavailable')
|
||||
with snapshot(os.environ.get('NK_SOPS_FILE', ''), plaintext=action == 'encrypt', lock=True) as target:
|
||||
path, directory, original, raw = target
|
||||
if action == 'encrypt':
|
||||
kind = plain_document(raw, path.suffix)
|
||||
if in_git(path.parent):
|
||||
tracked = subprocess.run(['git', '-C', str(path.parent), 'ls-files', '--error-unmatch', '--', path.name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
||||
if tracked.returncode != 1:
|
||||
raise Refused('tracked plaintext or unverifiable Git index requires incident review')
|
||||
metadata = {}
|
||||
else:
|
||||
kind, metadata = encrypted_document(raw, path, recipient)
|
||||
keyfd = None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='netkingdom-sops-write-home-') as isolated:
|
||||
env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': isolated, 'XDG_CONFIG_HOME': isolated,
|
||||
'GNUPGHOME': isolated, 'AWS_EC2_METADATA_DISABLED': 'true'}
|
||||
if action != 'encrypt':
|
||||
keyfd = base.private_key_fd()
|
||||
derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
pass_fds=(keyfd,), timeout=10)
|
||||
if derived.returncode or derived.stdout.strip() != recipient.encode():
|
||||
raise Refused('custody key does not match registered recipient')
|
||||
env['SOPS_AGE_KEY_FILE'] = f'/proc/self/fd/{keyfd}'
|
||||
formats = ['--input-type', kind, '--output-type', kind]
|
||||
if action == 'rotate':
|
||||
encrypted = sops(binary, ['--rotate', *formats], raw, env, isolated, keyfd)
|
||||
else:
|
||||
options = []
|
||||
if action == 'edit':
|
||||
# MAC verification must precede replacement of even a
|
||||
# syntactically valid encrypted document.
|
||||
sops(binary, ['--decrypt', *formats], raw, env, isolated, keyfd, discard=True)
|
||||
with snapshot(os.environ.get('NK_SOPS_INPUT', ''), plaintext=True, replacement=True) as replacement:
|
||||
plain = replacement[3]
|
||||
if plain_document(plain, replacement[0].suffix) != kind:
|
||||
raise Refused('replacement format must match the encrypted document')
|
||||
for name in POLICY:
|
||||
value = metadata.get(name)
|
||||
if value:
|
||||
if not isinstance(value, str): raise Refused('invalid encryption policy')
|
||||
options.extend(['--' + name.replace('_', '-'), value])
|
||||
if metadata.get('mac_only_encrypted'):
|
||||
options.append('--mac-only-encrypted')
|
||||
else:
|
||||
plain = raw
|
||||
encrypted = sops(binary, ['--encrypt', '--age', recipient, *formats, *options],
|
||||
plain, env, isolated, keyfd)
|
||||
encrypted_document(encrypted, path, recipient)
|
||||
if keyfd is not None:
|
||||
sops(binary, ['--decrypt', *formats], encrypted, env, isolated, keyfd, discard=True)
|
||||
if base.public_recipient() != recipient:
|
||||
raise Refused('registered recipient changed during operation')
|
||||
publish(path, directory, original, encrypted)
|
||||
finally:
|
||||
if keyfd is not None: os.close(keyfd)
|
||||
print('Encrypted file updated; no plaintext was printed. Retain approved independent recovery custody.')
|
||||
|
||||
|
||||
def main():
|
||||
for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): signal.signal(sig, base.interrupted)
|
||||
try:
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in {'encrypt', 'rotate', 'edit'}:
|
||||
raise Refused('unsupported SOPS write operation')
|
||||
write(sys.argv[1], base.public_recipient())
|
||||
return 0
|
||||
except Refused as exc:
|
||||
print('ERROR: ' + str(exc), file=sys.stderr)
|
||||
except (OSError, ValueError, TypeError, KeyError, ImportError, subprocess.SubprocessError):
|
||||
print('ERROR: SOPS write refused or failed; input and tool diagnostics suppressed.', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
244
tests/test_sops_write.py
Normal file
244
tests/test_sops_write.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""Real SOPS write-path regressions with disposable keys and plaintext only."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
SOPS=shutil.which('sops')
|
||||
pytestmark=pytest.mark.skipif(SOPS is None,reason='real SOPS required')
|
||||
PLAIN='synthetic-sops-write-value'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def case(tmp_path):
|
||||
repo=tmp_path/'repo';boot=repo/'sso-mfa/bootstrap';boot.mkdir(parents=True)
|
||||
shutil.copyfile(ROOT/'Makefile',repo/'Makefile')
|
||||
for name in ['sops-private.py','sops-write.py']:
|
||||
if (ROOT/'sso-mfa/bootstrap'/name).exists():shutil.copyfile(ROOT/'sso-mfa/bootstrap'/name,boot/name)
|
||||
(repo/'keys').mkdir()
|
||||
key=tmp_path/'disposable.key';subprocess.run(['age-keygen','-o',str(key)],capture_output=True,check=True)
|
||||
public=subprocess.check_output(['age-keygen','-y',str(key)],text=True).strip()
|
||||
(repo/'keys/age.pub').write_text(public+'\n')
|
||||
(repo/'.sops.yaml').write_text('creation_rules:\n - age: '+public+'\n')
|
||||
home=tmp_path/'home';home.mkdir(mode=0o700)
|
||||
private=tmp_path/'private';private.mkdir(mode=0o700)
|
||||
replacement=private/'replacement.json';replacement.write_text(json.dumps({'credential':PLAIN+'-edited'}));replacement.chmod(0o600)
|
||||
cipher=repo/'cipher.json'
|
||||
env={'HOME':str(home),'PATH':os.environ['PATH'],'NETKINGDOM_CUSTODY_AGE_KEY_FILE':str(key),'SOPS_AGE_KEY_FILE':str(key)}
|
||||
encrypted=subprocess.run([SOPS,'--encrypt','--age',public,'--input-type','json','--output-type','json','/dev/stdin'],input=json.dumps({'credential':PLAIN}).encode(),cwd=tmp_path,env=env,capture_output=True,check=True)
|
||||
cipher.write_bytes(encrypted.stdout)
|
||||
return {'repo':repo,'key':key,'public':public,'env':env,'cipher':cipher,'input':replacement,'tmp':tmp_path}
|
||||
|
||||
|
||||
def invoke(case,action,*,file=None,input=None,env=None):
|
||||
return subprocess.run(['make','-s','sops-'+action,'FILE='+str(file or case['cipher']),'INPUT='+str(input or case['input'])],cwd=case['repo'],env=env or case['env'],capture_output=True,text=True,timeout=15)
|
||||
|
||||
|
||||
def decode(case,file=None):
|
||||
result=subprocess.run([SOPS,'--decrypt',str(file or case['cipher'])],env=case['env'],capture_output=True,check=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def quiet(result):
|
||||
assert PLAIN not in result.stdout+result.stderr
|
||||
assert 'Traceback' not in result.stdout+result.stderr
|
||||
|
||||
|
||||
def plaintext(case,name='plain.json'):
|
||||
path=case['repo']/name;path.write_text(json.dumps({'credential':PLAIN}));path.chmod(0o600);return path
|
||||
|
||||
|
||||
def test_public_only_encrypt_roundtrip(case):
|
||||
path=plaintext(case);env=dict(case['env']);env.pop('NETKINGDOM_CUSTODY_AGE_KEY_FILE');env.pop('SOPS_AGE_KEY_FILE')
|
||||
result=invoke(case,'encrypt',file=path,env=env);assert result.returncode==0,result.stderr;quiet(result)
|
||||
assert decode(case,path)=={'credential':PLAIN}
|
||||
assert json.loads(path.read_text())['sops']['age'][0]['recipient']==case['public']
|
||||
assert path.stat().st_mode & 0o077==0
|
||||
|
||||
|
||||
def test_rotate_changes_ciphertext_not_plaintext_or_recipient(case):
|
||||
before=case['cipher'].read_bytes();result=invoke(case,'rotate');assert result.returncode==0,result.stderr;quiet(result)
|
||||
assert case['cipher'].read_bytes()!=before and decode(case)=={'credential':PLAIN}
|
||||
assert [r['recipient'] for r in json.loads(case['cipher'].read_text())['sops']['age']]==[case['public']]
|
||||
|
||||
|
||||
def test_edit_uses_private_replacement_and_never_launches_ambient_editor(case):
|
||||
marker=case['tmp']/'editor-called';editor=case['tmp']/'editor'
|
||||
editor.write_text('#!/bin/sh\ntouch '+str(marker)+'\nprintf '+PLAIN+'\\n >&2\n');editor.chmod(0o700)
|
||||
result=invoke(case,'edit',env={**case['env'],'SOPS_EDITOR':str(editor),'EDITOR':str(editor)})
|
||||
assert result.returncode==0,result.stderr;quiet(result)
|
||||
assert not marker.exists() and decode(case)=={'credential':PLAIN+'-edited'}
|
||||
assert json.loads(case['input'].read_text())=={'credential':PLAIN+'-edited'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name',['cipher; touch SHELL_EXECUTED; #.json','$(shell touch MAKE_EXECUTED).json','with spaces.json'])
|
||||
@pytest.mark.parametrize('action',['encrypt','rotate','edit'])
|
||||
def test_file_names_are_data_not_shell_or_make_programs(case,name,action):
|
||||
file=case['repo']/name
|
||||
if action=='encrypt':file=plaintext(case,name)
|
||||
else:file.write_bytes(case['cipher'].read_bytes())
|
||||
result=invoke(case,action,file=file);assert result.returncode==0,result.stderr;quiet(result)
|
||||
assert not (case['repo']/'SHELL_EXECUTED').exists() and not (case['repo']/'MAKE_EXECUTED').exists()
|
||||
assert decode(case,file)=={'credential':PLAIN+('-edited' if action=='edit' else '')}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action',['rotate','edit'])
|
||||
def test_explicit_key_required_despite_ambient_identity(case,action):
|
||||
before=case['cipher'].read_bytes();env=dict(case['env']);env.pop('NETKINGDOM_CUSTODY_AGE_KEY_FILE')
|
||||
result=invoke(case,action,env=env);assert result.returncode!=0;quiet(result)
|
||||
assert case['cipher'].read_bytes()==before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action',['rotate','edit'])
|
||||
def test_unauthorized_recipient_metadata_is_not_a_rotation_grant(case,action):
|
||||
other=case['tmp']/'other.key';subprocess.run(['age-keygen','-o',str(other)],capture_output=True,check=True)
|
||||
public=subprocess.check_output(['age-keygen','-y',str(other)],text=True).strip()
|
||||
data=json.loads(case['cipher'].read_text());data['sops']['age'].append({**data['sops']['age'][0],'recipient':public})
|
||||
case['cipher'].write_text(json.dumps(data));before=case['cipher'].read_bytes()
|
||||
result=invoke(case,action);assert result.returncode!=0;quiet(result);assert case['cipher'].read_bytes()==before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['public','symlink','hardlink','git','missing'])
|
||||
def test_edit_refuses_unsafe_input_without_changing_cipher(case,defect):
|
||||
file=case['input'];before=case['cipher'].read_bytes()
|
||||
if defect=='public':file.chmod(0o644)
|
||||
elif defect=='missing':file.unlink()
|
||||
elif defect=='symlink':
|
||||
target=file.with_name('other');file.rename(target);file.symlink_to(target)
|
||||
elif defect=='hardlink':os.link(file,file.with_name('alias'))
|
||||
else:subprocess.run(['git','init',str(file.parent)],capture_output=True,check=True)
|
||||
result=invoke(case,'edit');assert result.returncode!=0;quiet(result);assert case['cipher'].read_bytes()==before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['symlink','hardlink','public-write'])
|
||||
def test_cipher_path_cannot_redirect_or_alias_replacement(case,defect):
|
||||
file=case['cipher'];before=file.read_bytes()
|
||||
if defect=='symlink':
|
||||
target=file.with_name('other.json');file.rename(target);file.symlink_to(target)
|
||||
elif defect=='hardlink':os.link(file,file.with_name('alias.json'))
|
||||
else:file.chmod(0o666)
|
||||
result=invoke(case,'rotate');assert result.returncode!=0;quiet(result);assert file.read_bytes()==before
|
||||
|
||||
|
||||
def test_tracked_plaintext_refused_before_encryption(case):
|
||||
file=plaintext(case)
|
||||
subprocess.run(['git','init',str(case['repo'])],capture_output=True,check=True)
|
||||
subprocess.run(['git','add',file.name],cwd=case['repo'],capture_output=True,check=True)
|
||||
before=file.read_bytes();result=invoke(case,'encrypt',file=file)
|
||||
assert result.returncode!=0;quiet(result);assert file.read_bytes()==before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action', ['edit', 'rotate'])
|
||||
def test_tampered_cipher_cannot_be_replaced(case, action):
|
||||
data=json.loads(case['cipher'].read_text());data['credential']='tampered';case['cipher'].write_text(json.dumps(data));before=case['cipher'].read_bytes()
|
||||
result=invoke(case,action);assert result.returncode!=0;quiet(result);assert case['cipher'].read_bytes()==before
|
||||
|
||||
|
||||
def test_encryption_does_not_run_ambient_key_command(case):
|
||||
path=plaintext(case);marker=case['tmp']/'key-command';command=case['tmp']/'command'
|
||||
command.write_text('#!/bin/sh\ntouch '+str(marker)+'\n');command.chmod(0o700)
|
||||
env={**case['env'],'SOPS_AGE_KEY_CMD':str(command),'SOPS_KMS_ARN':'unadmitted','SOPS_CONFIG':str(case['repo']/'.sops.yaml')}
|
||||
result=invoke(case,'encrypt',file=path,env=env);assert result.returncode==0,result.stderr;quiet(result);assert not marker.exists()
|
||||
|
||||
|
||||
def test_malformed_plaintext_is_not_echoed_or_replaced(case):
|
||||
path=plaintext(case);path.write_text('invalid '+PLAIN);before=path.read_bytes()
|
||||
result=invoke(case,'encrypt',file=path);assert result.returncode!=0;quiet(result);assert path.read_bytes()==before
|
||||
|
||||
|
||||
def test_make_input_is_literal(case):
|
||||
replacement=case['input'].with_name('$(shell touch MAKE_INPUT_EXECUTED).json');case['input'].rename(replacement)
|
||||
result=invoke(case,'edit',input=replacement);assert result.returncode==0,result.stderr
|
||||
assert not (case['repo']/'MAKE_INPUT_EXECUTED').exists()
|
||||
assert decode(case)=={'credential':PLAIN+'-edited'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['hardlink','fifo','parent-symlink'])
|
||||
def test_custody_key_alias_or_nonregular_input_is_refused(case,defect):
|
||||
key=case['key'];env=dict(case['env']);before=case['cipher'].read_bytes()
|
||||
if defect=='hardlink':os.link(key,key.with_name('alias.key'))
|
||||
elif defect=='fifo':
|
||||
key.unlink();os.mkfifo(key,0o600)
|
||||
else:
|
||||
parent=case['tmp']/'key-directory';parent.mkdir(mode=0o700);key.rename(parent/'key');alias=case['tmp']/'alias';alias.symlink_to(parent);env['NETKINGDOM_CUSTODY_AGE_KEY_FILE']=str(alias/'key')
|
||||
result=invoke(case,'rotate',env=env);assert result.returncode!=0;quiet(result)
|
||||
assert case['cipher'].read_bytes()==before
|
||||
|
||||
|
||||
def test_edit_preserves_declared_encryption_policy(case):
|
||||
raw=json.dumps({'credential':PLAIN,'visible_unencrypted':'public-label'}).encode()
|
||||
encrypted=subprocess.run([SOPS,'--encrypt','--age',case['public'],'--input-type','json','--output-type','json','/dev/stdin'],input=raw,cwd=case['tmp'],env=case['env'],capture_output=True,check=True)
|
||||
case['cipher'].write_bytes(encrypted.stdout)
|
||||
case['input'].write_text(json.dumps({'credential':PLAIN+'-edited','visible_unencrypted':'public-label'}))
|
||||
result=invoke(case,'edit');assert result.returncode==0,result.stderr
|
||||
obj=json.loads(case['cipher'].read_text())
|
||||
assert obj['visible_unencrypted']=='public-label' and obj['credential'].startswith('ENC[')
|
||||
assert decode(case)=={'credential':PLAIN+'-edited','visible_unencrypted':'public-label'}
|
||||
|
||||
|
||||
def test_child_diagnostics_and_plaintext_output_are_suppressed(case):
|
||||
directory=case['tmp']/'bin';directory.mkdir();binary=directory/'sops'
|
||||
binary.write_text('#!/bin/sh\nprintf "'+PLAIN+'\\n"\nprintf "'+PLAIN+'\\n" >&2\nexit 1\n');binary.chmod(0o700)
|
||||
file=plaintext(case);before=file.read_bytes()
|
||||
result=invoke(case,'encrypt',file=file,env={**case['env'],'PATH':str(directory)+':'+case['env']['PATH']})
|
||||
assert result.returncode!=0;quiet(result);assert file.read_bytes()==before
|
||||
assert not list(file.parent.glob('.netkingdom-sops-write-*'))
|
||||
|
||||
|
||||
def test_source_change_during_encryption_is_not_overwritten(case):
|
||||
file=plaintext(case);directory=case['tmp']/'bin';directory.mkdir();binary=directory/'sops'
|
||||
binary.write_text('#!/usr/bin/python3\nimport subprocess,sys\nfrom pathlib import Path\nr=subprocess.run('+repr([SOPS])+ '+sys.argv[1:],input=sys.stdin.buffer.read(),capture_output=True)\nPath('+repr(str(file))+').write_text("concurrent-update")\nsys.stdout.buffer.write(r.stdout)\nsys.exit(r.returncode)\n');binary.chmod(0o700)
|
||||
result=invoke(case,'encrypt',file=file,env={**case['env'],'PATH':str(directory)+':'+case['env']['PATH']})
|
||||
assert result.returncode!=0;quiet(result);assert file.read_text()=='concurrent-update'
|
||||
assert not list(file.parent.glob('.netkingdom-sops-write-*'))
|
||||
|
||||
|
||||
def test_make_custody_command_cannot_execute_before_handoff(case):
|
||||
helper=case['repo']/'sso-mfa/bootstrap/sops-custody-unlock.sh'
|
||||
helper.write_text('#!/bin/bash\npython3 - "$@" <<\'PYTHON\'\nimport json,sys\nfrom pathlib import Path\nPath("handoff.json").write_text(json.dumps(sys.argv[1:]))\nPYTHON\n')
|
||||
command="printf 'safe'; touch OUTSIDE_CUSTODY; #"
|
||||
result=subprocess.run(['make','-s','sops-custody-run','COMMAND='+command],cwd=case['repo'],env=case['env'],capture_output=True,text=True,timeout=10)
|
||||
assert result.returncode==0,result.stderr
|
||||
assert not (case['repo']/'OUTSIDE_CUSTODY').exists()
|
||||
assert json.loads((case['repo']/'handoff.json').read_text())[-3:]==['bash','-lc',command]
|
||||
|
||||
|
||||
def test_yaml_encrypt_rotate_edit_roundtrip(case):
|
||||
import yaml
|
||||
file = case['repo'] / 'plain.yaml'
|
||||
file.write_text('credential: ' + PLAIN + '\n'); file.chmod(0o600)
|
||||
replacement = case['input'].with_suffix('.yaml')
|
||||
replacement.write_text('credential: ' + PLAIN + '-edited\n'); replacement.chmod(0o600)
|
||||
for action in ('encrypt', 'rotate', 'edit'):
|
||||
result = invoke(case, action, file=file, input=replacement)
|
||||
assert result.returncode == 0, result.stderr
|
||||
quiet(result)
|
||||
decrypted = subprocess.run([SOPS, '--decrypt', str(file)], env=case['env'], capture_output=True, check=True)
|
||||
assert yaml.safe_load(decrypted.stdout) == {'credential': PLAIN + ('-edited' if action == 'edit' else '')}
|
||||
|
||||
|
||||
def test_make_decrypt_output_is_literal(case):
|
||||
output = case['input'].with_name('$(shell touch MAKE_OUTPUT_EXECUTED).json')
|
||||
result = subprocess.run(['make', '-s', 'sops-decrypt', 'FILE=' + str(case['cipher']), 'OUTPUT=' + str(output)],
|
||||
cwd=case['repo'], env=case['env'], capture_output=True, text=True, timeout=15)
|
||||
assert result.returncode == 0, result.stderr
|
||||
quiet(result)
|
||||
assert not (case['repo'] / 'MAKE_OUTPUT_EXECUTED').exists()
|
||||
assert json.loads(output.read_text()) == {'credential': PLAIN}
|
||||
|
||||
|
||||
def test_make_custody_values_are_not_evaluated(case):
|
||||
helper = case['repo'] / 'sso-mfa/bootstrap/sops-custody-unlock.sh'
|
||||
helper.write_text('#!/bin/bash\nexec python3 -c \'import json,sys; from pathlib import Path; Path("handoff.json").write_text(json.dumps(sys.argv[1:]))\' "$@"\n')
|
||||
command = '$(shell touch MAKE_COMMAND_EXECUTED)'
|
||||
recipient = '$(shell touch MAKE_RECIPIENT_EXECUTED)'
|
||||
result = subprocess.run(['make', '-s', 'sops-custody-run', 'COMMAND=' + command, 'OPERATOR_AGE_PUBKEY=' + recipient],
|
||||
cwd=case['repo'], env=case['env'], capture_output=True, text=True, timeout=15)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert not (case['repo'] / 'MAKE_COMMAND_EXECUTED').exists()
|
||||
assert not (case['repo'] / 'MAKE_RECIPIENT_EXECUTED').exists()
|
||||
assert json.loads((case['repo'] / 'handoff.json').read_text()) == ['--expected-recipient', recipient, '--', 'bash', '-lc', command]
|
||||
|
|
@ -85,7 +85,7 @@ Concrete uncovered callers remain (T02 stays progress):
|
|||
legacy replacement paths now refuse before mutation; this is not rotation closure.
|
||||
- PostgreSQL/privacyIDEA/LLDAP/Authelia generators and the batch apply caller
|
||||
are repaired below. Other diagnostic/bootstrap/key-export callers and direct
|
||||
SOPS edit/encrypt/rotate remain; native effect/recovery evidence is mandatory.
|
||||
SOPS edit/encrypt/rotate are repaired below; native effect/recovery evidence is mandatory.
|
||||
|
||||
2026-10-12 Make caller repair: sops-setup now validates only the public recipient;
|
||||
creds-init no longer requires or installs a permanent private key. sops-decrypt
|
||||
|
|
@ -182,6 +182,44 @@ No live Secret, signing key, credential or deployment was changed. T02 remains
|
|||
progress for the explicit callers/lifecycle work above; T03 remains waiting for
|
||||
its owner-scoped native recovery ceremony.
|
||||
|
||||
2026-10-13 SOPS write caller repair: Make passes paths and custody arguments
|
||||
literally, removing shell/Make execution before handoff. Public-only encryption
|
||||
refuses tracked plaintext. Edit consumes private replacement JSON/YAML outside
|
||||
Git and verifies the original MAC; rotate changes the data key only. Both require
|
||||
explicit matching custody and exactly the independently registered recipient,
|
||||
refusing metadata-injected recipients. Child diagnostics/plaintext output are
|
||||
suppressed, policy is preserved, and ciphertext publication checks source version
|
||||
before atomic replacement. Shared private-key validation rejects links/FIFOs.
|
||||
A stat/rename race against noncooperating same-UID writers remains; no OS isolation
|
||||
or live recipient migration is claimed. Validation and exact source binding:
|
||||
Custodian `docs/evidence/2026-10-13-netkingdom-security/sops-write-callers.json`.
|
||||
|
||||
Remaining closure checklist (source review, not proof of current live use):
|
||||
- `privacyidea/enckey-bootstrap.sh` is still called by `creds-bootstrap-agent.sh`
|
||||
and documented by `creds-apply.sh` and the privacyIDEA README. It exports keys to
|
||||
ordinary overwriteable paths and uses client-side Secret apply. Repair private
|
||||
no-overwrite export, existing-key preservation and conditional DR Secret writes;
|
||||
verify the actual backup/restore contract before recovery acceptance.
|
||||
- `privacyidea/check-user-mfa-state.sh`, linked by the smooth-bootstrap guide,
|
||||
still reads retired `keycape-pi-token`, disables TLS verification, puts its token
|
||||
in curl argv and prints provider error text. Reconcile with the current scoped
|
||||
factor-reader owner contract and a bounded diagnostic interface.
|
||||
- `lldap/bootstrap-users.sh` still sources credentials, sends password/token in
|
||||
argv and can print auth responses. Inspect its group/user provisioning callers
|
||||
against the current User Engine owner path before replacing or retiring them.
|
||||
Targeted source findings also include `manage-group-members.sh`, `create-user.sh`,
|
||||
`break-glass.sh`, `dry-run-nonroot-user.sh` and `verify-t07.sh`; their caller and
|
||||
lifecycle review remains incomplete.
|
||||
- `privacyidea/repair-realm-live.sh` writes shell-escaped credentials for
|
||||
`bootstrap-realm.sh`, which still sources them. Reconcile both ends together;
|
||||
a private temporary directory alone does not establish the transport contract.
|
||||
- Reconcile the complete `docs/attended-procedure-inventory.md` against current
|
||||
scripts and Make/bootstrap/root-output callers. The selected findings above
|
||||
are not an exhaustive closure audit. Retain historical revocation and
|
||||
configuration-preserving rotation requirements already recorded here.
|
||||
- After source coverage, execute T03's exact owner-approved off-host recovery and
|
||||
current-consumer checks. Source regressions do not satisfy that gate.
|
||||
|
||||
## Verify owner-approved recovery and current native callers
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue