secrefs 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
secrefs-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Armistice Group
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
secrefs-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: secrefs
3
+ Version: 0.2.0
4
+ Summary: BYOV secret reference engine - expand sec:// references in memory at runtime
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: SecRefs
8
+ Author-email: hello@secrefs.com
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Provides-Extra: bitwarden
18
+ Requires-Dist: bitwarden-sdk (>=2.1,<3.0) ; extra == "bitwarden"
19
+ Requires-Dist: boto3 (>=1.34,<2.0)
20
+ Requires-Dist: hvac (>=2.1,<3.0)
21
+ Project-URL: Homepage, https://secrefs.com
22
+ Project-URL: Repository, https://github.com/secrefs/secrefs
23
+ Description-Content-Type: text/markdown
24
+
25
+ # secrefs (Python)
26
+
27
+ Python SDK/CLI parity with `@secrefs/node`. See the repo root README for the
28
+ full `sec://` reference spec and provider list.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install secrefs
34
+ # Bitwarden Secrets Manager support is an extra - see "Providers" below:
35
+ pip install 'secrefs[bitwarden]'
36
+ # or, in this monorepo:
37
+ poetry install
38
+ ```
39
+
40
+ ## Library usage
41
+
42
+ ```python
43
+ import asyncio
44
+ import os
45
+ from secrefs import sec_refs
46
+
47
+ async def main():
48
+ await sec_refs.init() # expands sec:// values in os.environ, in place
49
+ print(os.environ["DB_PASSWORD"])
50
+
51
+ asyncio.run(main())
52
+ ```
53
+
54
+ ## Two ways to expand, and when each is wrong
55
+
56
+ A `sec://` reference is a **stable name for a value that changes**. Which of
57
+ these you use decides whether that actually holds:
58
+
59
+ **At use (recommended).** Expand where the secret is consumed. Every call
60
+ re-fetches, so rotating the value at the source reaches this consumer with
61
+ no redeploy:
62
+
63
+ ```python
64
+ async def call_vendor_api():
65
+ key = await sec_refs.expand_string("sec://aws/hackerone#api_key")
66
+ ...
67
+ ```
68
+
69
+ **At load (`secrefs-py run`).** Expands once at startup and bakes plain
70
+ strings into the child's environment. Convenient, and the right choice for
71
+ short-lived processes like a CI job — but an environment variable is a
72
+ static string, so **a long-running process keeps the pre-rotation value
73
+ until it restarts**.
74
+
75
+ Network-backed providers re-fetch on every expansion by default. Concurrent
76
+ expansions of the same reference still share one request, and `cache_ttl_ms`
77
+ trades a bounded window of staleness for fewer round trips if you need it:
78
+
79
+ ```python
80
+ AWSSecretsManagerProvider(cache_ttl_ms=30_000) # rotation lands within 30s
81
+ ```
82
+
83
+ `LocalProvider` re-reads its JSON file per fetch for the same reason, so
84
+ editing `.secrefs.local.json` mid-session takes effect; `cache_file=True`
85
+ restores the old behavior.
86
+
87
+ ## Providers
88
+
89
+ | Alias | Backend | Ambient auth |
90
+ |---|---|---|
91
+ | `aws` | AWS Secrets Manager | boto3's default credential chain |
92
+ | `vault` | HashiCorp Vault (KV v1/v2) | `VAULT_ADDR` / `VAULT_TOKEN` |
93
+ | `bitwarden` | Bitwarden Secrets Manager | `BWS_ACCESS_TOKEN` / `BWS_ORGANIZATION_ID` |
94
+ | `local` | gitignored JSON file | n/a - development only |
95
+
96
+ `bitwarden` needs the `secrefs[bitwarden]` extra: Bitwarden secrets are
97
+ end-to-end encrypted, so only Bitwarden's own SDK can decrypt them, and it
98
+ ships as prebuilt native wheels for a fixed set of platforms. It's an extra
99
+ rather than a hard dependency so a platform it doesn't build for can still
100
+ install SecRefs.
101
+
102
+ ## Control-plane-sourced credentials
103
+
104
+ `AWSSecretsManagerProvider` and `BitwardenProvider` can source credentials
105
+ per request from a running control plane instead of the ambient environment
106
+ (see `docs/control-plane-design.md`), so each fetch is authenticated,
107
+ RBAC-checked and audited on the control plane's side:
108
+
109
+ ```python
110
+ from secrefs import AWSSecretsManagerProvider, ControlPlaneCredentialSource
111
+
112
+ AWSSecretsManagerProvider(
113
+ control_plane=ControlPlaneCredentialSource(
114
+ base_url=os.environ["SECREFS_CONTROL_PLANE_URL"],
115
+ token=os.environ["SECREFS_CONTROL_PLANE_TOKEN"],
116
+ alias="aws-prod",
117
+ )
118
+ )
119
+ ```
120
+
121
+ ## CLI usage
122
+
123
+ ```bash
124
+ secrefs-py run -- python app.py
125
+ secrefs-py check
126
+ ```
127
+
@@ -0,0 +1,102 @@
1
+ # secrefs (Python)
2
+
3
+ Python SDK/CLI parity with `@secrefs/node`. See the repo root README for the
4
+ full `sec://` reference spec and provider list.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install secrefs
10
+ # Bitwarden Secrets Manager support is an extra - see "Providers" below:
11
+ pip install 'secrefs[bitwarden]'
12
+ # or, in this monorepo:
13
+ poetry install
14
+ ```
15
+
16
+ ## Library usage
17
+
18
+ ```python
19
+ import asyncio
20
+ import os
21
+ from secrefs import sec_refs
22
+
23
+ async def main():
24
+ await sec_refs.init() # expands sec:// values in os.environ, in place
25
+ print(os.environ["DB_PASSWORD"])
26
+
27
+ asyncio.run(main())
28
+ ```
29
+
30
+ ## Two ways to expand, and when each is wrong
31
+
32
+ A `sec://` reference is a **stable name for a value that changes**. Which of
33
+ these you use decides whether that actually holds:
34
+
35
+ **At use (recommended).** Expand where the secret is consumed. Every call
36
+ re-fetches, so rotating the value at the source reaches this consumer with
37
+ no redeploy:
38
+
39
+ ```python
40
+ async def call_vendor_api():
41
+ key = await sec_refs.expand_string("sec://aws/hackerone#api_key")
42
+ ...
43
+ ```
44
+
45
+ **At load (`secrefs-py run`).** Expands once at startup and bakes plain
46
+ strings into the child's environment. Convenient, and the right choice for
47
+ short-lived processes like a CI job — but an environment variable is a
48
+ static string, so **a long-running process keeps the pre-rotation value
49
+ until it restarts**.
50
+
51
+ Network-backed providers re-fetch on every expansion by default. Concurrent
52
+ expansions of the same reference still share one request, and `cache_ttl_ms`
53
+ trades a bounded window of staleness for fewer round trips if you need it:
54
+
55
+ ```python
56
+ AWSSecretsManagerProvider(cache_ttl_ms=30_000) # rotation lands within 30s
57
+ ```
58
+
59
+ `LocalProvider` re-reads its JSON file per fetch for the same reason, so
60
+ editing `.secrefs.local.json` mid-session takes effect; `cache_file=True`
61
+ restores the old behavior.
62
+
63
+ ## Providers
64
+
65
+ | Alias | Backend | Ambient auth |
66
+ |---|---|---|
67
+ | `aws` | AWS Secrets Manager | boto3's default credential chain |
68
+ | `vault` | HashiCorp Vault (KV v1/v2) | `VAULT_ADDR` / `VAULT_TOKEN` |
69
+ | `bitwarden` | Bitwarden Secrets Manager | `BWS_ACCESS_TOKEN` / `BWS_ORGANIZATION_ID` |
70
+ | `local` | gitignored JSON file | n/a - development only |
71
+
72
+ `bitwarden` needs the `secrefs[bitwarden]` extra: Bitwarden secrets are
73
+ end-to-end encrypted, so only Bitwarden's own SDK can decrypt them, and it
74
+ ships as prebuilt native wheels for a fixed set of platforms. It's an extra
75
+ rather than a hard dependency so a platform it doesn't build for can still
76
+ install SecRefs.
77
+
78
+ ## Control-plane-sourced credentials
79
+
80
+ `AWSSecretsManagerProvider` and `BitwardenProvider` can source credentials
81
+ per request from a running control plane instead of the ambient environment
82
+ (see `docs/control-plane-design.md`), so each fetch is authenticated,
83
+ RBAC-checked and audited on the control plane's side:
84
+
85
+ ```python
86
+ from secrefs import AWSSecretsManagerProvider, ControlPlaneCredentialSource
87
+
88
+ AWSSecretsManagerProvider(
89
+ control_plane=ControlPlaneCredentialSource(
90
+ base_url=os.environ["SECREFS_CONTROL_PLANE_URL"],
91
+ token=os.environ["SECREFS_CONTROL_PLANE_TOKEN"],
92
+ alias="aws-prod",
93
+ )
94
+ )
95
+ ```
96
+
97
+ ## CLI usage
98
+
99
+ ```bash
100
+ secrefs-py run -- python app.py
101
+ secrefs-py check
102
+ ```
@@ -0,0 +1,56 @@
1
+ [tool.poetry]
2
+ name = "secrefs"
3
+ version = "0.2.0"
4
+ description = "BYOV secret reference engine - expand sec:// references in memory at runtime"
5
+ authors = ["SecRefs <hello@secrefs.com>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ homepage = "https://secrefs.com"
9
+ repository = "https://github.com/secrefs/secrefs"
10
+ packages = [{ include = "secrefs" }]
11
+
12
+ [tool.poetry.dependencies]
13
+ python = "^3.10"
14
+ boto3 = "^1.34"
15
+ hvac = "^2.1"
16
+ # Optional, unlike boto3/hvac: bitwarden-sdk publishes only prebuilt native
17
+ # wheels for a fixed set of platform tags with no source distribution, so a
18
+ # hard dependency would break installing secrefs at all on any platform
19
+ # Bitwarden doesn't build for - including for the many users who never write
20
+ # a sec://bitwarden/... reference. providers/bitwarden.py imports it at
21
+ # first use and explains how to install it if it's absent.
22
+ bitwarden-sdk = { version = "^2.1", optional = true }
23
+
24
+ [tool.poetry.extras]
25
+ bitwarden = ["bitwarden-sdk"]
26
+
27
+ [tool.poetry.group.dev.dependencies]
28
+ pytest = "^8.2"
29
+ pytest-asyncio = "^0.23"
30
+ mypy = "^1.10"
31
+
32
+ [tool.poetry.scripts]
33
+ secrefs-py = "secrefs.cli:main"
34
+
35
+ [tool.pytest.ini_options]
36
+ asyncio_mode = "auto"
37
+ testpaths = ["tests"]
38
+
39
+ [tool.mypy]
40
+ python_version = "3.10"
41
+ warn_unused_configs = true
42
+ strict = true
43
+
44
+ # boto3/hvac/bitwarden_sdk ship without inline type stubs (py.typed) or a
45
+ # matching stub-only package pinned in this project; suppress the import
46
+ # warning for just these rather than pulling in a heavy generated-stub
47
+ # dependency (e.g. boto3-stubs) not called for in the project spec.
48
+ # bitwarden_sdk is additionally an optional extra, so it may legitimately
49
+ # not be installed in the environment mypy runs in.
50
+ [[tool.mypy.overrides]]
51
+ module = ["boto3.*", "hvac.*", "bitwarden_sdk.*"]
52
+ ignore_missing_imports = true
53
+
54
+ [build-system]
55
+ requires = ["poetry-core"]
56
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,110 @@
1
+ import os
2
+ from typing import Dict, List, Optional
3
+
4
+ from .control_plane_client import (
5
+ ControlPlaneClient,
6
+ ControlPlaneCredentialSource,
7
+ ControlPlaneRequestError,
8
+ MintCredentialResponse,
9
+ MintedAWSCredentials,
10
+ MintedBitwardenCredentials,
11
+ )
12
+ from .parser import (
13
+ ParsedSecretRef,
14
+ SecRefParseError,
15
+ is_secret_ref,
16
+ parse_secret_ref,
17
+ try_parse_secret_ref,
18
+ )
19
+ from .providers.aws import AWSSecretsManagerProvider
20
+ from .providers.base import ProviderHealth, SecretFetchError, SecretFetchRequest, SecretProvider
21
+ from .providers.bitwarden import BitwardenProvider
22
+ from .providers.local import LocalProvider
23
+ from .providers.vault import VaultProvider
24
+ from .resolver import (
25
+ CheckResult,
26
+ ProviderRegistry,
27
+ ResolutionFailure,
28
+ SecRefsResolutionError,
29
+ check_references,
30
+ expand_environ,
31
+ expand_key_value_map,
32
+ )
33
+ from .ttl_cache import TtlCache
34
+
35
+
36
+ def create_default_providers() -> Dict[str, SecretProvider]:
37
+ """Builds the default provider registry: aws, vault, local, bitwarden."""
38
+ return {
39
+ "aws": AWSSecretsManagerProvider(),
40
+ "vault": VaultProvider(),
41
+ "local": LocalProvider(),
42
+ "bitwarden": BitwardenProvider(),
43
+ }
44
+
45
+
46
+ class SecRefs:
47
+ """Mirrors the Node SDK's `secRefs.init()` / `expandEnv()` / `expandString()` API."""
48
+
49
+ def __init__(
50
+ self,
51
+ providers: Optional[Dict[str, SecretProvider]] = None,
52
+ strict: bool = True,
53
+ ) -> None:
54
+ self.providers: Dict[str, SecretProvider] = providers or create_default_providers()
55
+ self.strict = strict
56
+
57
+ async def init(self) -> List[str]:
58
+ """Expands sec:// values found in os.environ, mutating it in place."""
59
+ return await expand_environ(self.providers, strict=self.strict)
60
+
61
+ async def expand_env(self, env: Dict[str, Optional[str]]) -> Dict[str, str]:
62
+ """Expands sec:// values in an arbitrary key/value map without touching os.environ."""
63
+ return await expand_key_value_map(env, self.providers, strict=self.strict)
64
+
65
+ async def expand_string(self, value: str) -> str:
66
+ """Expands a single string if it's a sec:// reference; otherwise returns it unchanged."""
67
+ if not is_secret_ref(value):
68
+ return value
69
+ result = await expand_key_value_map({"__value__": value}, self.providers, strict=True)
70
+ return result["__value__"]
71
+
72
+ async def check(self, env: Optional[Dict[str, Optional[str]]] = None) -> List[CheckResult]:
73
+ """Dry-run validation of every sec:// reference in `env` (defaults to os.environ)."""
74
+ return await check_references(env if env is not None else dict(os.environ), self.providers)
75
+
76
+
77
+ sec_refs = SecRefs()
78
+
79
+ __all__ = [
80
+ "SecRefs",
81
+ "sec_refs",
82
+ "create_default_providers",
83
+ "ParsedSecretRef",
84
+ "SecRefParseError",
85
+ "is_secret_ref",
86
+ "parse_secret_ref",
87
+ "try_parse_secret_ref",
88
+ "AWSSecretsManagerProvider",
89
+ "VaultProvider",
90
+ "LocalProvider",
91
+ "BitwardenProvider",
92
+ "SecretProvider",
93
+ "SecretFetchRequest",
94
+ "SecretFetchError",
95
+ "ProviderHealth",
96
+ "TtlCache",
97
+ "ControlPlaneClient",
98
+ "ControlPlaneCredentialSource",
99
+ "ControlPlaneRequestError",
100
+ "MintCredentialResponse",
101
+ "MintedAWSCredentials",
102
+ "MintedBitwardenCredentials",
103
+ "SecRefsResolutionError",
104
+ "ResolutionFailure",
105
+ "CheckResult",
106
+ "ProviderRegistry",
107
+ "expand_environ",
108
+ "expand_key_value_map",
109
+ "check_references",
110
+ ]
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import List
11
+
12
+ from . import SecRefs, SecRefsResolutionError
13
+ from .envfile import parse_env_file_text
14
+
15
+
16
+ def _load_dotenv(path: Path) -> None:
17
+ """Loads a `.env` file into `os.environ`, correctly preserving
18
+ `sec://...#field` fragments (see `envfile.py`). Mirrors dotenv.config()'s
19
+ default precedence: never overrides a value already present in
20
+ `os.environ` (e.g. set explicitly by the shell/CI)."""
21
+ if not path.exists():
22
+ return
23
+ parsed = parse_env_file_text(path.read_text(encoding="utf-8"))
24
+ for key, value in parsed.items():
25
+ os.environ.setdefault(key, value)
26
+
27
+
28
+ def _build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="secrefs-py",
31
+ description="BYOV secret reference engine - expand sec:// references in memory at runtime",
32
+ )
33
+ subparsers = parser.add_subparsers(dest="subcommand", required=True)
34
+
35
+ run_parser = subparsers.add_parser(
36
+ "run", help="Resolve sec:// references and spawn a child process"
37
+ )
38
+ run_parser.add_argument("--env-file", default=".env", help="path to a .env file to load first")
39
+ run_parser.add_argument(
40
+ "--no-env-file", action="store_true", help="skip loading a .env file"
41
+ )
42
+ run_parser.add_argument(
43
+ "command", nargs=argparse.REMAINDER, help="command to run, e.g. -- python app.py"
44
+ )
45
+
46
+ check_parser = subparsers.add_parser(
47
+ "check", help="Validate sec:// references and provider reachability"
48
+ )
49
+ check_parser.add_argument("--env-file", default=".env", help="path to a .env file to load first")
50
+ check_parser.add_argument(
51
+ "--no-env-file", action="store_true", help="skip loading a .env file"
52
+ )
53
+
54
+ return parser
55
+
56
+
57
+ async def _run(args: argparse.Namespace) -> int:
58
+ command: List[str] = [c for c in args.command if c != "--"]
59
+ if not command:
60
+ print(
61
+ "secrefs-py run: no command given. Usage: secrefs-py run -- <command> [args...]",
62
+ file=sys.stderr,
63
+ )
64
+ return 1
65
+
66
+ if not args.no_env_file:
67
+ _load_dotenv(Path(args.env_file))
68
+
69
+ instance = SecRefs()
70
+ try:
71
+ changed = await instance.init()
72
+ if changed:
73
+ print(
74
+ f"secrefs-py: resolved {len(changed)} secret reference(s): {', '.join(changed)}",
75
+ file=sys.stderr,
76
+ )
77
+ except SecRefsResolutionError as exc:
78
+ print("secrefs-py: failed to resolve one or more secret references:", file=sys.stderr)
79
+ print(str(exc), file=sys.stderr)
80
+ return 1
81
+
82
+ process = subprocess.Popen(command, env=os.environ.copy())
83
+
84
+ forwarded_signals = [
85
+ s for s in (signal.SIGINT, signal.SIGTERM, getattr(signal, "SIGHUP", None)) if s is not None
86
+ ]
87
+
88
+ def _forward(sig: int, _frame: object) -> None:
89
+ if process.poll() is None:
90
+ process.send_signal(sig)
91
+
92
+ original_handlers = {sig: signal.getsignal(sig) for sig in forwarded_signals}
93
+ for sig in forwarded_signals:
94
+ signal.signal(sig, _forward)
95
+
96
+ try:
97
+ return process.wait()
98
+ finally:
99
+ for sig, handler in original_handlers.items():
100
+ signal.signal(sig, handler)
101
+
102
+
103
+ async def _check(args: argparse.Namespace) -> int:
104
+ if not args.no_env_file:
105
+ _load_dotenv(Path(args.env_file))
106
+
107
+ instance = SecRefs()
108
+ results = await instance.check()
109
+
110
+ if not results:
111
+ print("secrefs-py check: no sec:// references found in the environment.")
112
+ return 0
113
+
114
+ ok_count = 0
115
+ for result in results:
116
+ icon = "✓" if result.ok else "✗"
117
+ print(f"{icon} {result.key} -> {result.ref}")
118
+ if result.ok:
119
+ ok_count += 1
120
+ else:
121
+ print(f" {result.message}")
122
+
123
+ print(f"\n{ok_count}/{len(results)} reference(s) resolved successfully.")
124
+ return 0 if ok_count == len(results) else 1
125
+
126
+
127
+ def main() -> None:
128
+ parser = _build_parser()
129
+ args = parser.parse_args()
130
+
131
+ if args.subcommand == "run":
132
+ exit_code = asyncio.run(_run(args))
133
+ else:
134
+ exit_code = asyncio.run(_check(args))
135
+
136
+ sys.exit(exit_code)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()