The SECURITY.md contract said "use gopass" but the code only used os.getenv. The deploy at /srv/pry/ had an .env file with secrets in it, which violates the SECURITY.md threat model. New module secrets_backend.py provides: get_secret(name, default) - resolves from gopass, env, or file set_secret(name, value) - writes to gopass backend_info() - diagnostic dict for /health or /status Backends selected by PRY_SECRET_BACKEND env var: gopass (default) - reads from gopass at pry/<name> env - reads from os.environ (PRY_<NAME> or PRY_<name>) file - reads from PRY_ENV_FILE (default: PRY_DATA_DIR/.env) auto - tries gopass, falls back to env Refactored call sites: auth.py: JWT_SECRET (was: os.getenv + ephemeral random default) x402.py: X402_WALLET, X402_FACILITATOR_URL (was: os.getenv) Seeded initial secrets on Talos (5 entries under pry/): jwt_secret, api_key, x402_wallet, x402_facilitator, ollama_url Updated .env.example header with backend selection guide and seed-secret instructions. Tests: 9/9 in test_secrets_backend.py pass. 36 tests in test_x402_mcp_spec.py + test_secrets_backend.py all pass. Verified end-to-end: >>> import x402 >>> x402.X402_WALLET '0xYourWalletAddressHere' >>> import auth >>> auth.JWT_SECRET 'change-me-rotate-quarterly' Follow-up: rotate jwt_secret and api_key to real random values. Document the rotation cadence in SECURITY.md.
108 lines
3.7 KiB
Python
108 lines
3.7 KiB
Python
"""Tests for secrets_backend module."""
|
|
# SPDX-License-Identifier: MIT
|
|
# Copyright (c) 2026 Rug Munch Media LLC
|
|
#
|
|
# Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper
|
|
# Licensed under MIT. See LICENSE.
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
import secrets_backend
|
|
from secrets_backend import (
|
|
BACKEND_AUTO,
|
|
BACKEND_ENV,
|
|
BACKEND_FILE,
|
|
BACKEND_GOPASS,
|
|
_normalize_name,
|
|
backend_info,
|
|
get_secret,
|
|
set_secret,
|
|
)
|
|
|
|
|
|
def test_normalize_name_adds_pry_prefix():
|
|
assert _normalize_name("api_key") == "pry/api_key"
|
|
assert _normalize_name("pry/api_key") == "pry/api_key"
|
|
# Names that already have a slash are treated as paths (not re-prefixed)
|
|
assert _normalize_name("PRY/api_key").startswith("pry/")
|
|
|
|
|
|
def test_get_secret_default_when_missing():
|
|
# Use a name that definitely doesn't exist anywhere
|
|
with patch.dict(os.environ, {"PRY_SECRET_BACKEND": "env"}, clear=False):
|
|
# Clear any env vars that might match
|
|
with patch.dict(os.environ, {}, clear=False):
|
|
value = get_secret("definitely_not_a_real_secret_name_xyz_12345", default="missing")
|
|
assert value == "missing"
|
|
|
|
|
|
def test_get_secret_from_env(monkeypatch):
|
|
monkeypatch.setenv("PRY_SECRET_BACKEND", "env")
|
|
monkeypatch.setenv("PRY_TEST_SECRET", "from-env")
|
|
value = get_secret("test_secret")
|
|
assert value == "from-env"
|
|
|
|
|
|
def test_get_secret_from_env_uppercase(monkeypatch):
|
|
"""Even when the caller passes lowercase, we should find PRY_UPPERCASE."""
|
|
monkeypatch.setenv("PRY_SECRET_BACKEND", "env")
|
|
monkeypatch.setenv("PRY_FOO", "upper-value")
|
|
value = get_secret("foo")
|
|
assert value == "upper-value"
|
|
|
|
|
|
def test_get_secret_falls_back_to_env_when_gopass_misses(monkeypatch):
|
|
"""When the active backend is gopass and a secret is not in gopass,
|
|
we should fall back to the env (so partial migrations work)."""
|
|
monkeypatch.setenv("PRY_SECRET_BACKEND", "gopass")
|
|
monkeypatch.setenv("PRY_FALLBACK_TEST", "env-fallback")
|
|
# gopass lookup will fail (entry doesn't exist), then we check env
|
|
with patch("secrets_backend._get_from_gopass", return_value=None):
|
|
value = get_secret("fallback_test")
|
|
assert value == "env-fallback"
|
|
|
|
|
|
def test_backend_info_returns_expected_keys():
|
|
info = backend_info()
|
|
assert "requested" in info
|
|
assert "active" in info
|
|
assert "gopass_available" in info
|
|
assert "gopass_path" in info
|
|
assert "env_file" in info
|
|
|
|
|
|
def test_get_secret_from_file(tmp_path, monkeypatch):
|
|
"""Read a secret from a .env file when backend is file."""
|
|
env_file = tmp_path / "test.env"
|
|
env_file.write_text("PRY_FROM_FILE=hello\n# comment\nPRY_OTHER=world\n")
|
|
monkeypatch.setenv("PRY_SECRET_BACKEND", "file")
|
|
monkeypatch.setenv("PRY_ENV_FILE", str(env_file))
|
|
# Reimport to pick up the new env file
|
|
importlib.reload(secrets_backend)
|
|
try:
|
|
value = secrets_backend.get_secret("from_file")
|
|
assert value == "hello"
|
|
finally:
|
|
# Restore
|
|
monkeypatch.delenv("PRY_ENV_FILE", raising=False)
|
|
importlib.reload(secrets_backend)
|
|
|
|
|
|
def test_set_secret_returns_false_when_gopass_unavailable(monkeypatch):
|
|
monkeypatch.setattr(secrets_backend, "_GOPASS_AVAILABLE", False)
|
|
# Force the global to be re-evaluated
|
|
monkeypatch.setattr(secrets_backend, "_gopass_available", lambda: False)
|
|
result = set_secret("test_secret", "value")
|
|
assert result is False
|
|
|
|
|
|
def test_detect_backend_invalid_falls_back_to_auto(monkeypatch):
|
|
monkeypatch.setenv("PRY_SECRET_BACKEND", "not-a-real-backend")
|
|
backend = secrets_backend._detect_backend()
|
|
assert backend == BACKEND_AUTO
|