Implement exec-file delivery
Write the selected field to a mode-0600 temp file, inject FIELD_FILE for the child only, then overwrite and unlink on every exit path. The value is not copied into the child environment. Assistant: grok Assistant-Session: 01a05f07-ae72-7781-9fcb-19efd61add00
This commit is contained in:
parent
afd1c8e593
commit
ce1790f267
7 changed files with 126 additions and 19 deletions
12
SCOPE.md
12
SCOPE.md
|
|
@ -202,10 +202,9 @@ secrets-engine audit <catalog-id> [--json]
|
||||||
secrets-engine evidence heartbeat|drain|classify
|
secrets-engine evidence heartbeat|drain|classify
|
||||||
```
|
```
|
||||||
|
|
||||||
The implemented exec adapters are `exec-env` and `npm-config`. `read-check` is
|
The implemented exec adapters are `exec-env`, `npm-config`, and `exec-file`.
|
||||||
verification, `approle-login` is auth-capability handoff metadata, and
|
`read-check` is verification and `approle-login` is auth-capability handoff
|
||||||
`exec-file` remains a reserved schema name without an exec adapter.
|
metadata. `secrets-engine wrap` implements response-wrapped operator handoff.
|
||||||
`secrets-engine wrap` implements response-wrapped operator handoff.
|
|
||||||
|
|
||||||
## Proven Operationally
|
## Proven Operationally
|
||||||
|
|
||||||
|
|
@ -222,9 +221,8 @@ verification, `approle-login` is auth-capability handoff metadata, and
|
||||||
## Not Implemented
|
## Not Implemented
|
||||||
|
|
||||||
- A service API, daemon, UI, queue, scheduler, or remote multi-user service.
|
- A service API, daemon, UI, queue, scheduler, or remote multi-user service.
|
||||||
- OpenBao JWT login and platform materialization for the implemented KeyCape
|
- Platform JWT mount/role materialization for the implemented KeyCape
|
||||||
service-auth provider.
|
service-auth / `service-jwt` provider.
|
||||||
- Native `exec-file` delivery.
|
|
||||||
- Provider-side rotation or coordinated multi-consumer rollout.
|
- Provider-side rotation or coordinated multi-consumer rollout.
|
||||||
- First-class rotate, compromise, reactivate, lease-status, or audit report
|
- First-class rotate, compromise, reactivate, lease-status, or audit report
|
||||||
commands; lifecycle operations currently execute plans without persistent
|
commands; lifecycle operations currently execute plans without persistent
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,9 @@ delivery_auth:
|
||||||
|
|
||||||
The current native implementation supports AppRole. An entry that declares
|
The current native implementation supports AppRole. An entry that declares
|
||||||
`exec-env`, `exec-file`, `npm-config`, `read-check`, or `wrapped` must therefore
|
`exec-env`, `exec-file`, `npm-config`, `read-check`, or `wrapped` must therefore
|
||||||
declare delivery auth. `exec-file` remains a reserved exec schema mode.
|
declare delivery auth. `exec-file` writes the value to a mode-0600 temp file
|
||||||
`wrapped` operator handoff is `secrets-engine wrap`; it is not an exec adapter.
|
and injects the path (`FIELD_FILE`) for the child only. `wrapped` operator
|
||||||
|
handoff is `secrets-engine wrap`; it is not an exec adapter.
|
||||||
|
|
||||||
Engine-managed AppRoles may bound `token_ttl`, `token_max_ttl`,
|
Engine-managed AppRoles may bound `token_ttl`, `token_max_ttl`,
|
||||||
`secret_id_ttl`, `secret_id_num_uses`, and `token_num_uses`. The admitted
|
`secret_id_ttl`, `secret_id_num_uses`, and `token_num_uses`. The admitted
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ secrets-engine provision <catalog-id> --stage <stage> --field NAME (--from-file
|
||||||
secrets-engine verify <catalog-id> [--field NAME] [--positive] [--negative] [--negative-token-file F]
|
secrets-engine verify <catalog-id> [--field NAME] [--positive] [--negative] [--negative-token-file F]
|
||||||
secrets-engine handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
|
secrets-engine handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
|
||||||
secrets-engine wrap <catalog-id> --out F [--ttl 15m]
|
secrets-engine wrap <catalog-id> --out F [--ttl 15m]
|
||||||
secrets-engine exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
|
secrets-engine exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env|exec-file] -- CMD...
|
||||||
secrets-engine policy publication <catalog-id>
|
secrets-engine policy publication <catalog-id>
|
||||||
secrets-engine route <catalog-id> [--json]
|
secrets-engine route <catalog-id> [--json]
|
||||||
secrets-engine revoke <catalog-id> [--dry-run]
|
secrets-engine revoke <catalog-id> [--dry-run]
|
||||||
|
|
@ -117,6 +117,11 @@ All three preserve externally managed auth and workload delivery. The legacy
|
||||||
`revoke` command is a compatibility alias for safe native deactivation, never
|
`revoke` command is a compatibility alias for safe native deactivation, never
|
||||||
KV destruction.
|
KV destruction.
|
||||||
|
|
||||||
|
`exec --mode exec-file` writes the selected field to a mode-0600 temp file and
|
||||||
|
sets `{FIELD}_FILE` to that path for the child only. The file is overwritten
|
||||||
|
and unlinked after the child exits. The value is not copied into the child
|
||||||
|
environment.
|
||||||
|
|
||||||
Exec and verification AppRole logins are scoped sessions. The issued token
|
Exec and verification AppRole logins are scoped sessions. The issued token
|
||||||
self-revokes on every exit path before exec starts (or when verification ends),
|
self-revokes on every exit path before exec starts (or when verification ends),
|
||||||
and evidence stores only a short accessor fingerprint plus cleanup outcome.
|
and evidence stores only a short accessor fingerprint plus cleanup outcome.
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ Command surface (FR7):
|
||||||
verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-file F]
|
verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-file F]
|
||||||
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
|
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
|
||||||
wrap <catalog-id> --out F [--ttl 15m]
|
wrap <catalog-id> --out F [--ttl 15m]
|
||||||
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
|
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env|exec-file] -- CMD...
|
||||||
route <catalog-id> [--json]
|
route <catalog-id> [--json]
|
||||||
revoke <catalog-id>
|
revoke <catalog-id>
|
||||||
session revoke --accessor-file F [--stage stage]
|
session revoke --accessor-file F [--stage stage]
|
||||||
|
|
@ -840,7 +840,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
ex = sub.add_parser("exec", help="run a command with the secret injected for the child only")
|
ex = sub.add_parser("exec", help="run a command with the secret injected for the child only")
|
||||||
ex.add_argument("--catalog", required=True)
|
ex.add_argument("--catalog", required=True)
|
||||||
ex.add_argument("--field", default=None)
|
ex.add_argument("--field", default=None)
|
||||||
ex.add_argument("--mode", default="auto", choices=("auto", "npm-config", "exec-env"))
|
ex.add_argument("--mode", default="auto", choices=("auto", "npm-config", "exec-env", "exec-file"))
|
||||||
add_token_arg(ex)
|
add_token_arg(ex)
|
||||||
ex.add_argument("command", nargs=argparse.REMAINDER,
|
ex.add_argument("command", nargs=argparse.REMAINDER,
|
||||||
help="command after '--'")
|
help="command after '--'")
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ Supported here:
|
||||||
- npm-config: write a temporary .npmrc with the auth token and point the child
|
- npm-config: write a temporary .npmrc with the auth token and point the child
|
||||||
at it via NPM_CONFIG_USERCONFIG. Preferred for `npm publish`.
|
at it via NPM_CONFIG_USERCONFIG. Preferred for `npm publish`.
|
||||||
- exec-env: inject the value as an environment variable for the child only.
|
- exec-env: inject the value as an environment variable for the child only.
|
||||||
|
- exec-file: write the value to a mode-0600 temp file and inject its path.
|
||||||
|
|
||||||
The parent shell never sees the value; the value is never logged. Child stdout/
|
The parent shell never sees the value; the value is never logged. Child stdout/
|
||||||
stderr is streamed through a redactor as a backstop.
|
stderr is streamed through a redactor as a backstop.
|
||||||
|
|
@ -119,10 +120,47 @@ def _npm_userconfig(registry: str, scope: str, token_env: str) -> Iterator[Path]
|
||||||
fh.write(f"{authkey}:_authToken=${{{token_env}}}\n")
|
fh.write(f"{authkey}:_authToken=${{{token_env}}}\n")
|
||||||
yield path
|
yield path
|
||||||
finally:
|
finally:
|
||||||
|
_unlink_secret_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _unlink_secret_file(path: Path) -> None:
|
||||||
|
"""Overwrite then unlink a secret file. Best-effort; never raises."""
|
||||||
|
try:
|
||||||
|
if path.is_file():
|
||||||
|
size = path.stat().st_size
|
||||||
|
with path.open("r+b") as fh:
|
||||||
|
fh.write(b"\0" * max(size, 1))
|
||||||
|
fh.flush()
|
||||||
|
os.fsync(fh.fileno())
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
try:
|
try:
|
||||||
path.unlink()
|
path.unlink()
|
||||||
except FileNotFoundError:
|
except OSError:
|
||||||
pass
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _secret_file(value: str) -> Iterator[Path]:
|
||||||
|
"""Write the value to a mode-0600 temp file; overwrite and unlink on exit."""
|
||||||
|
fd, name = tempfile.mkstemp(prefix="se-exec-", suffix=".tmp")
|
||||||
|
path = Path(name)
|
||||||
|
try:
|
||||||
|
os.fchmod(fd, 0o600)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
fd = -1
|
||||||
|
fh.write(value)
|
||||||
|
fh.write("\n")
|
||||||
|
yield path
|
||||||
|
finally:
|
||||||
|
if fd >= 0:
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_unlink_secret_file(path)
|
||||||
|
|
||||||
|
|
||||||
def _stream_redacted(proc: subprocess.Popen, secret: str) -> None:
|
def _stream_redacted(proc: subprocess.Popen, secret: str) -> None:
|
||||||
|
|
@ -156,9 +194,14 @@ def exec_with_secret(
|
||||||
|
|
||||||
declared = set(entry.delivery_modes)
|
declared = set(entry.delivery_modes)
|
||||||
if mode == "auto":
|
if mode == "auto":
|
||||||
mode = "npm-config" if "npm-config" in declared else (
|
if "npm-config" in declared:
|
||||||
"exec-env" if "exec-env" in declared else ""
|
mode = "npm-config"
|
||||||
)
|
elif "exec-env" in declared:
|
||||||
|
mode = "exec-env"
|
||||||
|
elif "exec-file" in declared:
|
||||||
|
mode = "exec-file"
|
||||||
|
else:
|
||||||
|
mode = ""
|
||||||
if not mode:
|
if not mode:
|
||||||
raise DeliveryError(
|
raise DeliveryError(
|
||||||
f"lane '{entry.id}' declares no exec-capable delivery mode "
|
f"lane '{entry.id}' declares no exec-capable delivery mode "
|
||||||
|
|
@ -200,6 +243,12 @@ def exec_with_secret(
|
||||||
child_env[env_name] = value
|
child_env[env_name] = value
|
||||||
return _spawn(command, child_env, value)
|
return _spawn(command, child_env, value)
|
||||||
|
|
||||||
|
if mode == "exec-file":
|
||||||
|
env_name = f"{field.upper()}_FILE"
|
||||||
|
with _secret_file(value) as secret_path:
|
||||||
|
child_env[env_name] = str(secret_path)
|
||||||
|
return _spawn(command, child_env, value)
|
||||||
|
|
||||||
raise DeliveryError(f"unsupported delivery mode '{mode}'")
|
raise DeliveryError(f"unsupported delivery mode '{mode}'")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,59 @@ def test_exec_env_injects_only_selected_declared_field(monkeypatch):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_file_injects_path_not_value_and_unlinks(monkeypatch, tmp_path):
|
||||||
|
data = copy.deepcopy(VALID)
|
||||||
|
data["delivery_modes"] = ["exec-file"]
|
||||||
|
entry = validate_entry(data)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_fetch(_client, _entry, field):
|
||||||
|
assert field == "api_token"
|
||||||
|
return "test-secret-value"
|
||||||
|
|
||||||
|
def fake_spawn(command, env, secret):
|
||||||
|
path = env["API_TOKEN_FILE"]
|
||||||
|
seen["path"] = path
|
||||||
|
seen["exists_during"] = __import__("pathlib").Path(path).is_file()
|
||||||
|
seen["mode"] = __import__("pathlib").Path(path).stat().st_mode & 0o777
|
||||||
|
seen["contents"] = __import__("pathlib").Path(path).read_text()
|
||||||
|
seen["has_value_env"] = "API_TOKEN" in env
|
||||||
|
assert command == ["probe"]
|
||||||
|
assert secret == "test-secret-value"
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr("secrets_engine.exec_delivery._fetch_value", fake_fetch)
|
||||||
|
monkeypatch.setattr("secrets_engine.exec_delivery._spawn", fake_spawn)
|
||||||
|
assert exec_with_secret(object(), entry, "api_token", ["probe"], mode="exec-file") == 0
|
||||||
|
assert seen["exists_during"] is True
|
||||||
|
assert seen["mode"] == 0o600
|
||||||
|
assert seen["contents"].strip() == "test-secret-value"
|
||||||
|
assert seen["has_value_env"] is False
|
||||||
|
assert not __import__("pathlib").Path(seen["path"]).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_file_unlinks_after_child_failure(monkeypatch):
|
||||||
|
data = copy.deepcopy(VALID)
|
||||||
|
data["delivery_modes"] = ["exec-file"]
|
||||||
|
entry = validate_entry(data)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"secrets_engine.exec_delivery._fetch_value",
|
||||||
|
lambda *_args, **_kwargs: "test-secret-value",
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_spawn(command, env, secret):
|
||||||
|
seen["path"] = env["API_TOKEN_FILE"]
|
||||||
|
raise RuntimeError("child exploded")
|
||||||
|
|
||||||
|
monkeypatch.setattr("secrets_engine.exec_delivery._spawn", fake_spawn)
|
||||||
|
with pytest.raises(RuntimeError, match="child exploded"):
|
||||||
|
exec_with_secret(object(), entry, "api_token", ["probe"], mode="exec-file")
|
||||||
|
assert seen["path"]
|
||||||
|
assert not __import__("pathlib").Path(seen["path"]).exists()
|
||||||
|
|
||||||
|
|
||||||
def test_exec_rejects_undeclared_field_before_fetch(monkeypatch):
|
def test_exec_rejects_undeclared_field_before_fetch(monkeypatch):
|
||||||
entry = validate_entry(VALID)
|
entry = validate_entry(VALID)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-08-21"
|
created: "2026-08-21"
|
||||||
updated: "2026-08-21"
|
updated: "2026-09-02"
|
||||||
state_hub_workstream_id: "31f7f8ea-7f73-516c-8877-f03a13f1db82"
|
state_hub_workstream_id: "31f7f8ea-7f73-516c-8877-f03a13f1db82"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -49,7 +49,8 @@ The current implementation is generic at the catalog level for KV
|
||||||
`mount`/`path`/`fields`, and `secrets-engine exec` supports `exec-env` plus the
|
`mount`/`path`/`fields`, and `secrets-engine exec` supports `exec-env` plus the
|
||||||
npm-specific `npm-config` adapter. The remaining delivery-mode names are not all
|
npm-specific `npm-config` adapter. The remaining delivery-mode names are not all
|
||||||
exec adapters: `read-check` is verification, `approle-login` is auth-capability
|
exec adapters: `read-check` is verification, `approle-login` is auth-capability
|
||||||
handoff, and `exec-file`/`wrapped` are not yet implemented by `exec`.
|
handoff. `exec-file` is implemented by `exec`; `wrapped` operator handoff is
|
||||||
|
`secrets-engine wrap`, not an exec adapter.
|
||||||
|
|
||||||
## Design constraints
|
## Design constraints
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue