feat(mvp): working secrets-engine CLI for the whynot-design npm publish lane
Implements SECRETS-WP-0002 end to end as a uv-managed Python package: - catalog: non-secret lane registry + strict validator (build/test/prod) - stage roles + OpenBao ACL policies; guards refuse wildcards, sys/, identity/, admin names, and cross-stage paths before any backend call - plan/apply: dry-run-first, idempotent policy + approle apply, decision-gated - decisions: State Hub lookup with local-fixture fallback; non-secret evidence to JSONL + hub progress, scrubbed of any value - provision/verify: mode-0600 file import + generated test values; positive/ negative checks that never print the value - exec delivery: `exec --catalog ... -- npm publish` injects the token via a temp .npmrc for the child only, cleaned up on exit/failure/interrupt - ops-warden routing contract + hardening backlog docs - 34 tests incl. live OpenBao integration; scripts/demo-e2e.sh runs the full chain against a throwaway bao dev server Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
58c24cff53
commit
a852d3f1ff
47 changed files with 3743 additions and 122 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
77
tests/test_catalog.py
Normal file
77
tests/test_catalog.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine.catalog import validate_entry, load_catalog
|
||||
from secrets_engine.config import repo_root
|
||||
from secrets_engine.errors import CatalogError
|
||||
|
||||
VALID = {
|
||||
"id": "test-lane",
|
||||
"owner": "team",
|
||||
"stage": "test",
|
||||
"mount": "secret",
|
||||
"path": "test/team/thing",
|
||||
"fields": ["api_token"],
|
||||
"consumers": [{"name": "c", "auth": "approle", "claim": "role:c"}],
|
||||
"delivery_modes": ["exec-env"],
|
||||
"approval": {"model": "bootstrap-only"},
|
||||
"verification": {"positive": "x", "negative": "y"},
|
||||
"rotation": {"ttl": "1h"},
|
||||
"deactivation": {"expectation": "delete"},
|
||||
"audit": {"evidence": "non-secret"},
|
||||
}
|
||||
|
||||
|
||||
def test_valid_entry_parses():
|
||||
e = validate_entry(VALID)
|
||||
assert e.id == "test-lane"
|
||||
assert e.policy_name == "se-test-test-lane"
|
||||
assert not e.approval_required()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["stage", "mount", "path", "fields", "approval", "delivery_modes"])
|
||||
def test_missing_required_field_rejected(field):
|
||||
data = copy.deepcopy(VALID)
|
||||
data.pop(field)
|
||||
with pytest.raises(CatalogError):
|
||||
validate_entry(data)
|
||||
|
||||
|
||||
def test_bad_stage_rejected():
|
||||
data = copy.deepcopy(VALID)
|
||||
data["stage"] = "staging"
|
||||
with pytest.raises(CatalogError):
|
||||
validate_entry(data)
|
||||
|
||||
|
||||
def test_unknown_delivery_mode_rejected():
|
||||
data = copy.deepcopy(VALID)
|
||||
data["delivery_modes"] = ["telepathy"]
|
||||
with pytest.raises(CatalogError):
|
||||
validate_entry(data)
|
||||
|
||||
|
||||
def test_wildcard_path_rejected():
|
||||
data = copy.deepcopy(VALID)
|
||||
data["path"] = "test/*"
|
||||
with pytest.raises(CatalogError):
|
||||
validate_entry(data)
|
||||
|
||||
|
||||
def test_inline_secret_value_rejected():
|
||||
data = copy.deepcopy(VALID)
|
||||
data["token"] = "npm_realvalue"
|
||||
with pytest.raises(CatalogError):
|
||||
validate_entry(data)
|
||||
|
||||
|
||||
def test_repo_catalog_loads_and_has_pilot():
|
||||
entries = load_catalog(repo_root() / "catalog")
|
||||
assert "whynot-design-npm-publish" in entries
|
||||
pilot = entries["whynot-design-npm-publish"]
|
||||
assert pilot.stage == "prod"
|
||||
assert pilot.approval_required()
|
||||
# build/test/prod stage separation is representable
|
||||
stages = {e.stage for e in entries.values()}
|
||||
assert {"build", "prod"} <= stages
|
||||
52
tests/test_decisions.py
Normal file
52
tests/test_decisions.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import pytest
|
||||
|
||||
from secrets_engine.catalog import validate_entry
|
||||
from secrets_engine.decisions import Decision, require_approved, resolve_decision
|
||||
from secrets_engine.errors import DecisionError
|
||||
|
||||
from tests.test_catalog import VALID
|
||||
|
||||
|
||||
def _approved(model="decision"):
|
||||
d = dict(VALID, approval={"model": model, "decision_ref": "x"})
|
||||
return validate_entry(d)
|
||||
|
||||
|
||||
def test_bootstrap_only_needs_no_decision():
|
||||
e = validate_entry(dict(VALID, approval={"model": "bootstrap-only"}))
|
||||
require_approved(e, None) # must not raise
|
||||
|
||||
|
||||
def test_unapproved_decision_refused():
|
||||
e = _approved()
|
||||
d = Decision(id="d", title="t", status="pending", superseded_by=None, source="hub")
|
||||
with pytest.raises(DecisionError):
|
||||
require_approved(e, d)
|
||||
|
||||
|
||||
def test_superseded_decision_refused():
|
||||
e = _approved()
|
||||
d = Decision(id="d", title="t", status="resolved", superseded_by="d2", source="hub")
|
||||
with pytest.raises(DecisionError):
|
||||
require_approved(e, d)
|
||||
|
||||
|
||||
def test_approved_decision_passes():
|
||||
e = _approved()
|
||||
d = Decision(id="d", title="t", status="resolved", superseded_by=None, source="hub")
|
||||
require_approved(e, d) # must not raise
|
||||
|
||||
|
||||
def test_local_fixture_resolves(tmp_path):
|
||||
(tmp_path / ".decisions").mkdir()
|
||||
(tmp_path / ".decisions" / "myref.yaml").write_text(
|
||||
"id: myref\ntitle: t\nstatus: resolved\nsuperseded_by: null\n"
|
||||
)
|
||||
d = resolve_decision(hub_url="http://127.0.0.1:1", repo_root=tmp_path, decision_ref="myref")
|
||||
assert d.source == "local-fixture"
|
||||
assert d.is_approved()
|
||||
|
||||
|
||||
def test_missing_decision_raises(tmp_path):
|
||||
with pytest.raises(DecisionError):
|
||||
resolve_decision(hub_url="http://127.0.0.1:1", repo_root=tmp_path, decision_ref="nope")
|
||||
67
tests/test_guards.py
Normal file
67
tests/test_guards.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Negative checks: a plan that would grant broad power must fail closed."""
|
||||
import pytest
|
||||
|
||||
from secrets_engine.catalog import validate_entry
|
||||
from secrets_engine.errors import PolicyGuardError
|
||||
from secrets_engine.plan import build_plan
|
||||
from secrets_engine.roles import assert_path_in_stage, assert_policy_safe
|
||||
|
||||
from tests.test_catalog import VALID
|
||||
|
||||
|
||||
def _entry(**over):
|
||||
d = dict(VALID)
|
||||
d.update(over)
|
||||
return validate_entry(d)
|
||||
|
||||
|
||||
def test_wildcard_policy_path_refused():
|
||||
with pytest.raises(PolicyGuardError):
|
||||
assert_policy_safe("se-test-x", {"secret/*": ["read"]})
|
||||
|
||||
|
||||
def test_sys_path_refused():
|
||||
with pytest.raises(PolicyGuardError):
|
||||
assert_policy_safe("se-test-x", {"sys/policies/acl/x": ["read"]})
|
||||
|
||||
|
||||
def test_identity_path_refused():
|
||||
with pytest.raises(PolicyGuardError):
|
||||
assert_policy_safe("se-test-x", {"identity/entity/x": ["read"]})
|
||||
|
||||
|
||||
def test_admin_policy_name_refused():
|
||||
with pytest.raises(PolicyGuardError):
|
||||
assert_policy_safe("platform-admin", {"secret/data/x": ["read"]})
|
||||
|
||||
|
||||
def test_broad_capability_refused():
|
||||
with pytest.raises(PolicyGuardError):
|
||||
assert_policy_safe("se-test-x", {"secret/data/x": ["sudo"]})
|
||||
|
||||
|
||||
def test_out_of_stage_path_refused():
|
||||
# a 'test' lane pointing into the build prefix is rejected
|
||||
e = _entry(stage="test", path="build/sneaky/thing")
|
||||
with pytest.raises(PolicyGuardError):
|
||||
assert_path_in_stage(e)
|
||||
|
||||
|
||||
def test_build_lane_must_use_build_prefix():
|
||||
e = _entry(stage="build", path="random/thing")
|
||||
with pytest.raises(PolicyGuardError):
|
||||
assert_path_in_stage(e)
|
||||
|
||||
|
||||
def test_stage_mismatch_in_plan_refused():
|
||||
e = _entry(stage="test", path="test/team/thing")
|
||||
with pytest.raises(PolicyGuardError):
|
||||
build_plan(e, "prod")
|
||||
|
||||
|
||||
def test_valid_plan_builds():
|
||||
e = _entry(stage="test", path="test/team/thing")
|
||||
plan = build_plan(e, "test", decision_id="d1")
|
||||
assert plan.policy_name == "se-test-test-lane"
|
||||
assert any(a.kind == "approle" for a in plan.actions)
|
||||
assert "secret/data/test/team/thing" in plan.policy_hcl
|
||||
103
tests/test_integration_bao.py
Normal file
103
tests/test_integration_bao.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Live integration test against a throwaway OpenBao dev server.
|
||||
|
||||
Skipped automatically if the `bao` CLI is not on PATH. Boots an in-memory dev
|
||||
server on a private port, then drives apply -> provision -> verify(+/-) ->
|
||||
exec-delivery and asserts the value is reachable by the child but not the parent.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine.apply import apply_plan
|
||||
from secrets_engine.catalog import get_entry
|
||||
from secrets_engine.config import repo_root
|
||||
from secrets_engine.exec_delivery import exec_with_secret
|
||||
from secrets_engine.openbao import OpenBaoClient
|
||||
from secrets_engine.plan import build_plan
|
||||
from secrets_engine.provision import provision_from_file
|
||||
from secrets_engine.verify import verify_negative, verify_positive
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("bao") is None and shutil.which("vault") is None,
|
||||
reason="no OpenBao/Vault CLI on PATH",
|
||||
)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
return port
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bao_dev():
|
||||
bao = shutil.which("bao") or shutil.which("vault")
|
||||
port = _free_port()
|
||||
addr = f"http://127.0.0.1:{port}"
|
||||
token = "se-test-root"
|
||||
proc = subprocess.Popen(
|
||||
[bao, "server", "-dev", f"-dev-root-token-id={token}",
|
||||
f"-dev-listen-address=127.0.0.1:{port}"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
client = OpenBaoClient(addr=addr, token=token, bao_bin=bao)
|
||||
for _ in range(50):
|
||||
if client.is_reachable():
|
||||
break
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
proc.kill()
|
||||
pytest.fail("dev OpenBao did not become reachable")
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
proc.kill()
|
||||
|
||||
|
||||
def test_full_chain(bao_dev, tmp_path):
|
||||
client = bao_dev
|
||||
entry = get_entry(repo_root() / "catalog", "whynot-design-npm-publish")
|
||||
|
||||
plan = build_plan(entry, "prod", decision_id="test")
|
||||
apply_plan(client, entry, plan)
|
||||
|
||||
# provision from a mode-0600 file outside the repo (tmp_path is outside)
|
||||
tokenfile = tmp_path / "tok"
|
||||
tokenfile.write_text("npm_integrationTESTvalue1234567890")
|
||||
os.chmod(tokenfile, 0o600)
|
||||
provision_from_file(client, entry, "npm_token", tokenfile)
|
||||
|
||||
pos = verify_positive(client, entry, "npm_token")
|
||||
assert pos.passed, pos.detail
|
||||
neg = verify_negative(client, entry)
|
||||
assert neg.passed, neg.detail
|
||||
|
||||
# exec delivery: child can resolve token via npmrc; assert via a probe script
|
||||
probe = tmp_path / "probe.sh"
|
||||
probe.write_text(
|
||||
"#!/usr/bin/env bash\n"
|
||||
'grep -q _authToken "$NPM_CONFIG_USERCONFIG" && echo CHILD_HAS_TOKEN\n'
|
||||
)
|
||||
os.chmod(probe, 0o755)
|
||||
rc = exec_with_secret(client, entry, "npm_token", [str(probe)], mode="npm-config")
|
||||
assert rc == 0
|
||||
# the parent process never received the value as an env var
|
||||
assert "SE_NPM_TOKEN" not in os.environ
|
||||
|
||||
|
||||
def test_idempotent_apply(bao_dev):
|
||||
client = bao_dev
|
||||
entry = get_entry(repo_root() / "catalog", "whynot-design-npm-publish")
|
||||
plan = build_plan(entry, "prod", decision_id="test")
|
||||
first = apply_plan(client, entry, plan)
|
||||
second = apply_plan(client, entry, plan)
|
||||
# policy should be reported unchanged on the second apply
|
||||
assert any("unchanged" in s for s in second.skipped)
|
||||
41
tests/test_redact_evidence.py
Normal file
41
tests/test_redact_evidence.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import json
|
||||
|
||||
from secrets_engine.evidence import EvidenceWriter, _scrub
|
||||
from secrets_engine.redact import looks_secret, redact_text
|
||||
|
||||
|
||||
def test_redact_known_token_shapes():
|
||||
assert "npm_" not in redact_text("token=npm_abcdEFGH12345678abcd")
|
||||
assert "REDACTED" in redact_text("token=npm_abcdEFGH12345678abcd")
|
||||
assert "ghp_" not in redact_text("ghp_0123456789abcdef0123")
|
||||
|
||||
|
||||
def test_redact_extra_literal():
|
||||
out = redact_text("the value is hunter2hunter2", extra=["hunter2hunter2"])
|
||||
assert "hunter2" not in out
|
||||
|
||||
|
||||
def test_looks_secret():
|
||||
assert looks_secret("npm_token")
|
||||
assert looks_secret("API_KEY")
|
||||
assert not looks_secret("path")
|
||||
|
||||
|
||||
def test_scrub_drops_secret_keys_and_redacts():
|
||||
scrubbed = _scrub({"token": "npm_realvalue123456789", "path": "a/b", "note": "ghp_0123456789abcdef0123"})
|
||||
assert scrubbed["token"].startswith("<omitted")
|
||||
assert scrubbed["path"] == "a/b"
|
||||
assert "ghp_" not in scrubbed["note"]
|
||||
|
||||
|
||||
def test_evidence_record_has_no_value(tmp_path):
|
||||
w = EvidenceWriter(evidence_dir=tmp_path, hub_url="") # hub disabled
|
||||
rec = w.record(
|
||||
"provision", result="from-file", catalog_id="lane", stage="prod",
|
||||
detail={"field": "npm_token", "value": "npm_shouldnotappear123"},
|
||||
)
|
||||
blob = json.dumps(rec)
|
||||
assert "npm_shouldnotappear123" not in blob
|
||||
# written to disk too
|
||||
files = list(tmp_path.glob("evidence-*.jsonl"))
|
||||
assert files and "npm_shouldnotappear123" not in files[0].read_text()
|
||||
Loading…
Add table
Add a link
Reference in a new issue