mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-02 10:52:05 +00:00
Add agent security controls, broker auth, and capability discovery
This commit is contained in:
1 parent
1febbd4b2c
commit
fe96ccd7c7
15 files changed
+2381
-99
No files matched your search
@@ -5,6 +5,7 @@ import base64
|
||||
import pytest
|
||||
|
||||
from seedpass import api
|
||||
import seedpass.core.agent_export_policy as export_policy
|
||||
import string
|
||||
from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
@@ -462,6 +463,7 @@ async def test_vault_unlock_endpoint(client):
|
||||
res = await cl.post("/api/v1/vault/unlock", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["status"] == "unlocked"
|
||||
assert "help_hint" in res.json()
|
||||
assert called["password"] == "pw"
|
||||
|
||||
|
||||
@@ -610,6 +612,42 @@ async def test_vault_export_endpoint(client, tmp_path):
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_vault_export_endpoint_denied_for_agent_profile(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
"X-SeedPass-Agent-Profile": "true",
|
||||
}
|
||||
res = await cl.post("/api/v1/vault/export", headers=headers)
|
||||
assert res.status_code == 403
|
||||
assert "Policy denied full vault export" in res.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_totp_export_endpoint_denied_for_agent_profile(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
cl, token = client
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
'{"allow_kinds":["password"],"allow_export_import":false}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-SeedPass-Password": "pw",
|
||||
"X-SeedPass-Agent-Profile": "true",
|
||||
}
|
||||
res = await cl.get("/api/v1/totp/export", headers=headers)
|
||||
assert res.status_code == 403
|
||||
assert "Policy denied TOTP export" in res.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_backup_parent_seed_endpoint(client, tmp_path):
|
||||
cl, token = client
|
||||
|
||||
@@ -84,6 +84,36 @@ def test_agent_init_requires_password_env(monkeypatch, tmp_path):
|
||||
assert "Missing password env var" in combined
|
||||
|
||||
|
||||
def test_agent_init_keyring_broker(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
calls = {}
|
||||
|
||||
def fake_resolve_password(**kwargs):
|
||||
calls.update(kwargs)
|
||||
return "AgentPass123!"
|
||||
|
||||
monkeypatch.setattr(agent_cli, "resolve_broker_password", fake_resolve_password)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"agent",
|
||||
"init",
|
||||
"--seed",
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||
"--auth-broker",
|
||||
"keyring",
|
||||
"--broker-service",
|
||||
"seedpass",
|
||||
"--broker-account",
|
||||
"ci-agent",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert calls["broker"] == "keyring"
|
||||
assert calls["broker_service"] == "seedpass"
|
||||
assert calls["broker_account"] == "ci-agent"
|
||||
|
||||
|
||||
def test_agent_get_requires_fingerprint(monkeypatch):
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
result = runner.invoke(app, ["agent", "get", "example"])
|
||||
@@ -120,7 +150,9 @@ def test_agent_get_password_json(monkeypatch):
|
||||
lambda **kwargs: {"allow_kinds": ["password"], "deny_private_reveal": []},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
|
||||
result = runner.invoke(
|
||||
app, ["--fingerprint", "ABC123", "agent", "get", "example", "--reveal"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "ok"
|
||||
@@ -163,7 +195,7 @@ def test_agent_get_denied_by_policy(monkeypatch):
|
||||
assert result.exit_code == 1
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "denied"
|
||||
assert payload["reason"] in {"kind_not_allowed", "private_kind_blocked"}
|
||||
assert payload["reason"].startswith("policy_deny:")
|
||||
|
||||
|
||||
def test_agent_get_ambiguous_returns_error_payload(monkeypatch):
|
||||
@@ -228,7 +260,9 @@ def test_agent_get_key_value_json(monkeypatch):
|
||||
lambda **kwargs: {"allow_kinds": ["key_value"], "deny_private_reveal": []},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "key"])
|
||||
result = runner.invoke(
|
||||
app, ["--fingerprint", "ABC123", "agent", "get", "key", "--reveal"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "ok"
|
||||
@@ -236,6 +270,147 @@ def test_agent_get_key_value_json(monkeypatch):
|
||||
assert payload["secret"] == "v123"
|
||||
|
||||
|
||||
def test_agent_get_safe_output_masks_by_default(monkeypatch):
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"PasswordManager",
|
||||
lambda fingerprint, password: SimpleNamespace(
|
||||
fingerprint=fingerprint, password=password
|
||||
),
|
||||
)
|
||||
|
||||
class DummyEntryService:
|
||||
def __init__(self, _pm):
|
||||
pass
|
||||
|
||||
def search_entries(self, _q):
|
||||
return [(2, "Example", "", "", False, EntryType.PASSWORD)]
|
||||
|
||||
def retrieve_entry(self, _i):
|
||||
return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12}
|
||||
|
||||
def generate_password(self, _length, _index):
|
||||
return "secret-pass"
|
||||
|
||||
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"_load_policy",
|
||||
lambda **kwargs: {
|
||||
"version": 1,
|
||||
"default_effect": "allow",
|
||||
"rules": [],
|
||||
"output": {"safe_output_default": True},
|
||||
"allow_kinds": ["password"],
|
||||
"deny_private_reveal": [],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["safe_output"] is True
|
||||
assert payload["secret"] != "secret-pass"
|
||||
|
||||
|
||||
def test_agent_token_issue_and_use_limit(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"PasswordManager",
|
||||
lambda fingerprint, password: SimpleNamespace(
|
||||
fingerprint=fingerprint, password=password
|
||||
),
|
||||
)
|
||||
|
||||
class DummyEntryService:
|
||||
def __init__(self, _pm):
|
||||
pass
|
||||
|
||||
def search_entries(self, _q):
|
||||
return [(2, "Example", "", "", False, EntryType.PASSWORD)]
|
||||
|
||||
def retrieve_entry(self, _i):
|
||||
return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12}
|
||||
|
||||
def generate_password(self, _length, _index):
|
||||
return "secret-pass"
|
||||
|
||||
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"_load_policy",
|
||||
lambda **kwargs: {
|
||||
"version": 1,
|
||||
"default_effect": "allow",
|
||||
"rules": [],
|
||||
"output": {"safe_output_default": False},
|
||||
"allow_kinds": ["password"],
|
||||
"deny_private_reveal": [],
|
||||
},
|
||||
)
|
||||
|
||||
issue = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"agent",
|
||||
"token-issue",
|
||||
"--name",
|
||||
"ci-agent",
|
||||
"--scope",
|
||||
"read",
|
||||
"--kind",
|
||||
"password",
|
||||
"--uses",
|
||||
"1",
|
||||
"--ttl",
|
||||
"600",
|
||||
],
|
||||
)
|
||||
assert issue.exit_code == 0
|
||||
issued_payload = json.loads(issue.stdout)
|
||||
token = issued_payload["token"]
|
||||
|
||||
first = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"--fingerprint",
|
||||
"ABC123",
|
||||
"agent",
|
||||
"get",
|
||||
"example",
|
||||
"--token",
|
||||
token,
|
||||
"--reveal",
|
||||
],
|
||||
)
|
||||
assert first.exit_code == 0
|
||||
first_payload = json.loads(first.stdout)
|
||||
assert first_payload["status"] == "ok"
|
||||
assert first_payload["token_uses_remaining"] == 0
|
||||
|
||||
second = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"--fingerprint",
|
||||
"ABC123",
|
||||
"agent",
|
||||
"get",
|
||||
"example",
|
||||
"--token",
|
||||
token,
|
||||
"--reveal",
|
||||
],
|
||||
)
|
||||
assert second.exit_code == 1
|
||||
second_payload = json.loads(second.stdout)
|
||||
assert second_payload["status"] == "denied"
|
||||
assert second_payload["reason"] == "token_exhausted"
|
||||
|
||||
|
||||
def test_agent_get_unsupported_kind_payload(monkeypatch):
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
monkeypatch.setattr(
|
||||
@@ -288,3 +463,129 @@ def test_agent_get_denied_when_policy_invalid(monkeypatch, tmp_path):
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "denied"
|
||||
assert payload["reason"] == "invalid_policy"
|
||||
|
||||
|
||||
def test_agent_get_keyring_defaults_account_to_fingerprint(monkeypatch):
|
||||
calls = {}
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"resolve_broker_password",
|
||||
lambda **kwargs: calls.update(kwargs) or "pw",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"PasswordManager",
|
||||
lambda fingerprint, password: SimpleNamespace(
|
||||
fingerprint=fingerprint, password=password
|
||||
),
|
||||
)
|
||||
|
||||
class DummyEntryService:
|
||||
def __init__(self, _pm):
|
||||
pass
|
||||
|
||||
def search_entries(self, _q):
|
||||
return [(2, "Example", "", "", False, EntryType.PASSWORD)]
|
||||
|
||||
def retrieve_entry(self, _i):
|
||||
return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12}
|
||||
|
||||
def generate_password(self, _length, _index):
|
||||
return "secret-pass"
|
||||
|
||||
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"_load_policy",
|
||||
lambda **kwargs: {
|
||||
"version": 1,
|
||||
"default_effect": "allow",
|
||||
"rules": [],
|
||||
"output": {"safe_output_default": False},
|
||||
"allow_kinds": ["password"],
|
||||
"deny_private_reveal": [],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"--fingerprint",
|
||||
"ABC123",
|
||||
"agent",
|
||||
"get",
|
||||
"example",
|
||||
"--auth-broker",
|
||||
"keyring",
|
||||
"--broker-service",
|
||||
"seedpass",
|
||||
"--reveal",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert calls["broker"] == "keyring"
|
||||
assert calls["broker_account"] == "ABC123"
|
||||
|
||||
|
||||
def test_agent_bootstrap_context(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "bootstrap-context"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["fingerprint"] == "ABC123"
|
||||
assert payload["policy"]["status"] == "ok"
|
||||
assert "supported" in payload["auth_brokers"]
|
||||
assert "agent token-issue" in payload["commands"]["tokens"]
|
||||
|
||||
|
||||
def test_agent_bootstrap_context_invalid_policy(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text("{", encoding="utf-8")
|
||||
result = runner.invoke(app, ["agent", "bootstrap-context"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["policy"]["status"] == "invalid"
|
||||
|
||||
|
||||
def test_agent_posture_check_clean(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
result = runner.invoke(app, ["agent", "posture-check"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["finding_count"] == 0
|
||||
assert payload["highest_severity"] == "info"
|
||||
|
||||
|
||||
def test_agent_posture_check_invalid_policy_fails(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text("{", encoding="utf-8")
|
||||
result = runner.invoke(app, ["agent", "posture-check", "--fail-on", "critical"])
|
||||
assert result.exit_code == 1
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["policy_status"] == "invalid"
|
||||
assert payload["highest_severity"] == "critical"
|
||||
assert any(f["id"] == "policy_invalid" for f in payload["findings"])
|
||||
|
||||
|
||||
def test_agent_posture_check_high_risk_policy(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
policy = {
|
||||
"version": 1,
|
||||
"default_effect": "allow",
|
||||
"rules": [],
|
||||
"output": {"safe_output_default": False, "redact_fields": ["secret"]},
|
||||
"export": {"allow_full_vault": True},
|
||||
"allow_export_import": True,
|
||||
"allow_kinds": ["password"],
|
||||
"deny_private_reveal": [],
|
||||
}
|
||||
(tmp_path / "agent_policy.json").write_text(json.dumps(policy), encoding="utf-8")
|
||||
result = runner.invoke(app, ["agent", "posture-check", "--fail-on", "high"])
|
||||
assert result.exit_code == 1
|
||||
payload = json.loads(result.stdout)
|
||||
ids = {f["id"] for f in payload["findings"]}
|
||||
assert "policy_default_allow" in ids
|
||||
assert "safe_output_disabled" in ids
|
||||
assert "export_import_allowed" in ids
|
||||
@@ -24,9 +24,35 @@ def test_cli_vault_unlock(monkeypatch):
|
||||
result = runner.invoke(app, ["vault", "unlock"])
|
||||
assert result.exit_code == 0
|
||||
assert "Unlocked in" in result.stdout
|
||||
assert "seedpass --help" in result.stdout
|
||||
assert called["pw"] == "pw"
|
||||
|
||||
|
||||
def test_cli_vault_unlock_with_env_broker(monkeypatch):
|
||||
called = {}
|
||||
broker_called = {}
|
||||
|
||||
def unlock_vault(pw):
|
||||
called["pw"] = pw
|
||||
return 0.25
|
||||
|
||||
pm = SimpleNamespace(unlock_vault=unlock_vault, select_fingerprint=lambda fp: None)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
|
||||
monkeypatch.setattr(
|
||||
"seedpass.cli.vault.resolve_broker_password",
|
||||
lambda **kwargs: broker_called.update(kwargs) or "broker-pw",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli.typer,
|
||||
"prompt",
|
||||
lambda *a, **k: (_ for _ in ()).throw(AssertionError("prompt should not be called")),
|
||||
)
|
||||
result = runner.invoke(app, ["vault", "unlock", "--auth-broker", "env"])
|
||||
assert result.exit_code == 0
|
||||
assert called["pw"] == "broker-pw"
|
||||
assert broker_called["broker"] == "env"
|
||||
|
||||
|
||||
def test_cli_entry_add_search_sync(monkeypatch):
|
||||
calls = {}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from seedpass.cli import common as cli_common
|
||||
from seedpass.cli import api as cli_api
|
||||
from seedpass import cli
|
||||
from seedpass.core.entry_types import EntryType
|
||||
import seedpass.core.agent_export_policy as export_policy
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -85,6 +86,18 @@ def test_vault_export(monkeypatch, tmp_path):
|
||||
assert out_path.read_bytes() == b"data"
|
||||
|
||||
|
||||
def test_vault_export_denied_for_agent_profile_by_default(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
monkeypatch.setattr(cli_common.VaultService, "export_profile", lambda self: b"data")
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: SimpleNamespace())
|
||||
out_path = tmp_path / "out.json"
|
||||
result = runner.invoke(
|
||||
app, ["vault", "export", "--file", str(out_path), "--agent-profile"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "Policy denied full vault export" in result.stdout
|
||||
|
||||
|
||||
def test_vault_import(monkeypatch, tmp_path):
|
||||
called = {}
|
||||
|
||||
@@ -386,6 +399,24 @@ def test_generate_password(monkeypatch):
|
||||
assert "secretpw" in result.stdout
|
||||
|
||||
|
||||
def test_capabilities_text():
|
||||
result = runner.invoke(app, ["capabilities"])
|
||||
assert result.exit_code == 0
|
||||
assert "SeedPass Capabilities" in result.stdout
|
||||
assert "Auth brokers" in result.stdout
|
||||
|
||||
|
||||
def test_capabilities_json():
|
||||
result = runner.invoke(app, ["capabilities", "--format", "json"])
|
||||
assert result.exit_code == 0
|
||||
import json
|
||||
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["schema_version"] == 1
|
||||
assert "cli" in payload["interfaces"]
|
||||
assert "agent" in payload["interfaces"]["cli"]["root_commands"]
|
||||
|
||||
|
||||
def test_api_start_passes_fingerprint(monkeypatch):
|
||||
"""Ensure the API start command forwards the selected fingerprint."""
|
||||
called = {}
|
||||
@@ -402,6 +433,41 @@ def test_api_start_passes_fingerprint(monkeypatch):
|
||||
assert called.get("fp") == "abc"
|
||||
|
||||
|
||||
def test_api_start_unlock_uses_env_broker(monkeypatch):
|
||||
called = {}
|
||||
broker_called = {}
|
||||
|
||||
def fake_start(fingerprint=None, unlock_password=None):
|
||||
called["fp"] = fingerprint
|
||||
called["pw"] = unlock_password
|
||||
return "tok"
|
||||
|
||||
monkeypatch.setattr(cli_api.api_module, "start_server", fake_start)
|
||||
monkeypatch.setattr(cli_api, "uvicorn", SimpleNamespace(run=lambda *a, **k: None))
|
||||
monkeypatch.setattr(
|
||||
cli_api,
|
||||
"resolve_broker_password",
|
||||
lambda **kwargs: broker_called.update(kwargs) or "broker-pw",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"--fingerprint",
|
||||
"abc",
|
||||
"api",
|
||||
"start",
|
||||
"--unlock",
|
||||
"--auth-broker",
|
||||
"env",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert called.get("fp") == "abc"
|
||||
assert called.get("pw") == "broker-pw"
|
||||
assert broker_called.get("broker") == "env"
|
||||
|
||||
|
||||
def test_entry_list_passes_fingerprint(monkeypatch):
|
||||
"""Ensure entry commands receive the fingerprint."""
|
||||
called = {}
|
||||
@@ -564,6 +630,26 @@ def test_entry_export_totp(monkeypatch, tmp_path):
|
||||
assert called.get("called") is True
|
||||
|
||||
|
||||
def test_entry_export_totp_denied_for_agent_profile(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(export_policy, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text(
|
||||
'{"allow_kinds":["password"],"allow_export_import":false}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(export_totp_entries=lambda seed: {"entries": []}),
|
||||
parent_seed="seed",
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
|
||||
out = tmp_path / "t.json"
|
||||
result = runner.invoke(
|
||||
app, ["entry", "export-totp", "--file", str(out), "--agent-profile"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "Policy denied TOTP export" in result.stdout
|
||||
|
||||
|
||||
def test_entry_totp_codes(monkeypatch):
|
||||
called = {}
|
||||
|
||||
|
||||
Reference in new issue
Block a user