hermes-vault-secret-source 0.1.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.
- hermes_vault_secret_source-0.1.0/LICENSE +21 -0
- hermes_vault_secret_source-0.1.0/PKG-INFO +198 -0
- hermes_vault_secret_source-0.1.0/README.md +170 -0
- hermes_vault_secret_source-0.1.0/hermes_vault_secret_source/__init__.py +280 -0
- hermes_vault_secret_source-0.1.0/hermes_vault_secret_source/_client.py +211 -0
- hermes_vault_secret_source-0.1.0/hermes_vault_secret_source.egg-info/PKG-INFO +198 -0
- hermes_vault_secret_source-0.1.0/hermes_vault_secret_source.egg-info/SOURCES.txt +11 -0
- hermes_vault_secret_source-0.1.0/hermes_vault_secret_source.egg-info/dependency_links.txt +1 -0
- hermes_vault_secret_source-0.1.0/hermes_vault_secret_source.egg-info/entry_points.txt +2 -0
- hermes_vault_secret_source-0.1.0/hermes_vault_secret_source.egg-info/requires.txt +4 -0
- hermes_vault_secret_source-0.1.0/hermes_vault_secret_source.egg-info/top_level.txt +1 -0
- hermes_vault_secret_source-0.1.0/pyproject.toml +63 -0
- hermes_vault_secret_source-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cryptoyasenka
|
|
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.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hermes-vault-secret-source
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HashiCorp Vault secret source for Hermes Agent (bulk KV v2).
|
|
5
|
+
Author: cryptoyasenka
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/cryptoyasenka/hermes-vault-secret-source
|
|
8
|
+
Project-URL: Repository, https://github.com/cryptoyasenka/hermes-vault-secret-source
|
|
9
|
+
Project-URL: Issues, https://github.com/cryptoyasenka/hermes-vault-secret-source/issues
|
|
10
|
+
Keywords: hermes,hermes-agent,vault,hashicorp,secrets,plugin
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: hvac>=2.0
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# hermes-vault-secret-source
|
|
30
|
+
|
|
31
|
+
[](https://github.com/cryptoyasenka/hermes-vault-secret-source/actions/workflows/ci.yml)
|
|
32
|
+
|
|
33
|
+
A [HashiCorp Vault](https://developer.hashicorp.com/vault) secret source for
|
|
34
|
+
[Hermes Agent](https://github.com/NousResearch/hermes-agent), packaged as a
|
|
35
|
+
standalone pip plugin.
|
|
36
|
+
|
|
37
|
+
At Hermes startup it reads one Vault **KV v2** secret path and injects every
|
|
38
|
+
key found there as an environment variable, the same way the bundled Bitwarden
|
|
39
|
+
Secrets Manager source injects a whole project. This is a **bulk** source, so
|
|
40
|
+
explicit per-variable bindings from mapped sources (like 1Password) take
|
|
41
|
+
precedence over it.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install "hermes-vault-secret-source @ git+https://github.com/cryptoyasenka/hermes-vault-secret-source"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The package is not on PyPI yet, so install it straight from the repository as
|
|
50
|
+
shown above. Once it is published, this simplifies to:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install hermes-vault-secret-source
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Either way this pulls in [`hvac`](https://hvac.readthedocs.io/), the official
|
|
57
|
+
Python client for Vault. Install it into the same environment as `hermes-agent`.
|
|
58
|
+
|
|
59
|
+
Hermes discovers the plugin automatically through the `hermes_agent.plugins`
|
|
60
|
+
entry point, but plugins are opt-in. Enable it once:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
hermes plugins enable hermes-hashicorp-vault
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Configure
|
|
67
|
+
|
|
68
|
+
Add a `vault` block under `secrets` in your Hermes config:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
secrets:
|
|
72
|
+
vault:
|
|
73
|
+
enabled: true
|
|
74
|
+
# addr is read from $VAULT_ADDR by default; set `addr` to hard-code it.
|
|
75
|
+
path: apps/my-agent # KV v2 path whose keys become env vars
|
|
76
|
+
mount_point: secret # KV v2 mount (default: secret)
|
|
77
|
+
auth_method: token # "token" (default) or "approle"
|
|
78
|
+
cache_ttl_seconds: 300 # 0 disables caching
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Credentials are never written into config; they come from environment
|
|
82
|
+
variables:
|
|
83
|
+
|
|
84
|
+
| Auth method | Env vars used |
|
|
85
|
+
|-------------|--------------------------------------------------|
|
|
86
|
+
| `token` | `VAULT_ADDR`, `VAULT_TOKEN` |
|
|
87
|
+
| `approle` | `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID` |
|
|
88
|
+
|
|
89
|
+
The env-var names are overridable per field (`addr_env`, `token_env`,
|
|
90
|
+
`role_id_env`, `secret_id_env`) if your deployment uses different names.
|
|
91
|
+
|
|
92
|
+
### All config keys
|
|
93
|
+
|
|
94
|
+
| Key | Default | Meaning |
|
|
95
|
+
|---------------------------|----------------|-------------------------------------------------|
|
|
96
|
+
| `enabled` | `false` | Master switch |
|
|
97
|
+
| `addr` | `""` | Vault URL; overrides `addr_env` when set |
|
|
98
|
+
| `addr_env` | `VAULT_ADDR` | Env var holding the Vault URL |
|
|
99
|
+
| `path` | `""` | KV v2 path to read (required) |
|
|
100
|
+
| `mount_point` | `secret` | KV v2 secrets-engine mount |
|
|
101
|
+
| `auth_method` | `token` | `token` or `approle` |
|
|
102
|
+
| `token_env` | `VAULT_TOKEN` | Env var with the token (token auth) |
|
|
103
|
+
| `role_id_env` | `VAULT_ROLE_ID`| Env var with the role_id (approle) |
|
|
104
|
+
| `secret_id_env` | `VAULT_SECRET_ID` | Env var with the secret_id (approle) |
|
|
105
|
+
| `namespace` | `""` | Vault Enterprise namespace |
|
|
106
|
+
| `verify` | `true` | TLS verify: `true`/`false` or a CA-bundle path |
|
|
107
|
+
| `cache_ttl_seconds` | `300` | Disk+memory cache TTL; `0` disables caching |
|
|
108
|
+
| `request_timeout_seconds` | `30` | Per-request Vault HTTP timeout |
|
|
109
|
+
| `override_existing` | `true` | Vault values overwrite existing env values |
|
|
110
|
+
|
|
111
|
+
## How it behaves
|
|
112
|
+
|
|
113
|
+
* **Never raises.** A misconfigured or unreachable Vault produces a clean
|
|
114
|
+
`FetchResult` with an `ErrorKind` (`not_configured`, `auth_failed`,
|
|
115
|
+
`ref_invalid`, `network`, `timeout`, ...), never a startup crash.
|
|
116
|
+
* **Protects its own credentials.** The auth env vars (`VAULT_ADDR`,
|
|
117
|
+
`VAULT_TOKEN`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`) are marked protected, so a
|
|
118
|
+
secret stored in Vault cannot clobber the credential used to reach Vault.
|
|
119
|
+
* **Caches values, never tokens.** The optional on-disk cache stores only the
|
|
120
|
+
resolved secret values; the token/secret_id are SHA-256 fingerprinted before
|
|
121
|
+
they touch the cache key.
|
|
122
|
+
* **Skips non-env-safe keys.** A KV key that is not a valid environment
|
|
123
|
+
variable name is skipped with a warning rather than injected.
|
|
124
|
+
|
|
125
|
+
## Threat model
|
|
126
|
+
|
|
127
|
+
This plugin runs on the Hermes startup path with access to Vault credentials, so
|
|
128
|
+
its security posture is deliberately narrow. Every protection below is already
|
|
129
|
+
implemented; each maps to a concrete mechanism in the source.
|
|
130
|
+
|
|
131
|
+
### Startup cannot be crashed by this source
|
|
132
|
+
|
|
133
|
+
`fetch()` never raises. Every configuration problem returns a `FetchResult` with
|
|
134
|
+
a machine-readable `ErrorKind` (`not_configured`, `auth_failed`, `ref_invalid`,
|
|
135
|
+
`network`, `timeout`, `binary_missing`, `internal`), and the backend in
|
|
136
|
+
`_client.py` only ever raises `RuntimeError`, which `fetch()` catches and
|
|
137
|
+
classifies. A misconfigured, unreachable, or unauthenticated Vault degrades to a
|
|
138
|
+
clean skip, never a traceback on the non-interactive startup path.
|
|
139
|
+
|
|
140
|
+
### A Vault secret cannot hijack the credential used to reach Vault
|
|
141
|
+
|
|
142
|
+
`protected_env_vars()` marks the auth env vars (`VAULT_ADDR`, `VAULT_TOKEN`,
|
|
143
|
+
`VAULT_ROLE_ID`, `VAULT_SECRET_ID`, or their configured overrides) as protected.
|
|
144
|
+
The orchestrator will not overwrite a protected variable, so a key that happens
|
|
145
|
+
to be named `VAULT_TOKEN` inside the Vault payload cannot clobber the token the
|
|
146
|
+
process is already using to authenticate. Names that are not valid env-var
|
|
147
|
+
identifiers are dropped from the protected set, so a misconfigured section cannot
|
|
148
|
+
silently weaken it.
|
|
149
|
+
|
|
150
|
+
### Tokens never reach the on-disk cache in the clear
|
|
151
|
+
|
|
152
|
+
The optional two-layer cache stores only resolved secret values. The Vault token
|
|
153
|
+
(or the AppRole `role_id` and `secret_id`) is SHA-256 fingerprinted by
|
|
154
|
+
`_fingerprint()` before it becomes part of the cache key, so neither the in-memory
|
|
155
|
+
key nor the `vault_cache.json` file on disk contains the raw credential. Caching
|
|
156
|
+
is opt-out: `cache_ttl_seconds: 0` disables it entirely.
|
|
157
|
+
|
|
158
|
+
### Only environment-safe keys are injected
|
|
159
|
+
|
|
160
|
+
Each KV v2 key is validated with the host's `is_valid_env_name()` before it is
|
|
161
|
+
contributed. A key that is not a valid environment-variable name is skipped with
|
|
162
|
+
a warning instead of being injected, so a stray Vault key cannot produce a
|
|
163
|
+
malformed process environment.
|
|
164
|
+
|
|
165
|
+
### TLS verification is on by default
|
|
166
|
+
|
|
167
|
+
`verify` defaults to `true`. It is disabled only when the operator explicitly
|
|
168
|
+
sets a false-y value, and a string value is treated as a CA-bundle path. TLS
|
|
169
|
+
therefore fails closed rather than open.
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
Requires a `hermes-agent` source checkout (for the `agent.*` packages and the
|
|
174
|
+
conformance kit at `tests/secret_sources/conformance.py`).
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
# host = hermes-agent checkout, plugin = this repo. Put the host on PYTHONPATH so
|
|
178
|
+
# `import tests` resolves to the host conformance package. This repo keeps its own
|
|
179
|
+
# tests in `checks/` (not `tests/`) precisely so it cannot shadow that package.
|
|
180
|
+
PYTHONPATH="/path/to/hermes-agent:$(pwd)" pytest
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`checks/test_conformance.py` runs the host `SecretSourceConformance` contract;
|
|
184
|
+
`checks/test_vault_source.py` covers behavior with `hvac` mocked (no network, no
|
|
185
|
+
Vault binary needed).
|
|
186
|
+
|
|
187
|
+
To run against a real dev server:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
vault server -dev # prints VAULT_ADDR + a root token
|
|
191
|
+
export VAULT_ADDR=http://127.0.0.1:8200
|
|
192
|
+
export VAULT_TOKEN=<root-token-from-output>
|
|
193
|
+
vault kv put secret/apps/my-agent API_KEY=abc DB_URL=postgres://x
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# hermes-vault-secret-source
|
|
2
|
+
|
|
3
|
+
[](https://github.com/cryptoyasenka/hermes-vault-secret-source/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
A [HashiCorp Vault](https://developer.hashicorp.com/vault) secret source for
|
|
6
|
+
[Hermes Agent](https://github.com/NousResearch/hermes-agent), packaged as a
|
|
7
|
+
standalone pip plugin.
|
|
8
|
+
|
|
9
|
+
At Hermes startup it reads one Vault **KV v2** secret path and injects every
|
|
10
|
+
key found there as an environment variable, the same way the bundled Bitwarden
|
|
11
|
+
Secrets Manager source injects a whole project. This is a **bulk** source, so
|
|
12
|
+
explicit per-variable bindings from mapped sources (like 1Password) take
|
|
13
|
+
precedence over it.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install "hermes-vault-secret-source @ git+https://github.com/cryptoyasenka/hermes-vault-secret-source"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The package is not on PyPI yet, so install it straight from the repository as
|
|
22
|
+
shown above. Once it is published, this simplifies to:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install hermes-vault-secret-source
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Either way this pulls in [`hvac`](https://hvac.readthedocs.io/), the official
|
|
29
|
+
Python client for Vault. Install it into the same environment as `hermes-agent`.
|
|
30
|
+
|
|
31
|
+
Hermes discovers the plugin automatically through the `hermes_agent.plugins`
|
|
32
|
+
entry point, but plugins are opt-in. Enable it once:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
hermes plugins enable hermes-hashicorp-vault
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Configure
|
|
39
|
+
|
|
40
|
+
Add a `vault` block under `secrets` in your Hermes config:
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
secrets:
|
|
44
|
+
vault:
|
|
45
|
+
enabled: true
|
|
46
|
+
# addr is read from $VAULT_ADDR by default; set `addr` to hard-code it.
|
|
47
|
+
path: apps/my-agent # KV v2 path whose keys become env vars
|
|
48
|
+
mount_point: secret # KV v2 mount (default: secret)
|
|
49
|
+
auth_method: token # "token" (default) or "approle"
|
|
50
|
+
cache_ttl_seconds: 300 # 0 disables caching
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Credentials are never written into config; they come from environment
|
|
54
|
+
variables:
|
|
55
|
+
|
|
56
|
+
| Auth method | Env vars used |
|
|
57
|
+
|-------------|--------------------------------------------------|
|
|
58
|
+
| `token` | `VAULT_ADDR`, `VAULT_TOKEN` |
|
|
59
|
+
| `approle` | `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID` |
|
|
60
|
+
|
|
61
|
+
The env-var names are overridable per field (`addr_env`, `token_env`,
|
|
62
|
+
`role_id_env`, `secret_id_env`) if your deployment uses different names.
|
|
63
|
+
|
|
64
|
+
### All config keys
|
|
65
|
+
|
|
66
|
+
| Key | Default | Meaning |
|
|
67
|
+
|---------------------------|----------------|-------------------------------------------------|
|
|
68
|
+
| `enabled` | `false` | Master switch |
|
|
69
|
+
| `addr` | `""` | Vault URL; overrides `addr_env` when set |
|
|
70
|
+
| `addr_env` | `VAULT_ADDR` | Env var holding the Vault URL |
|
|
71
|
+
| `path` | `""` | KV v2 path to read (required) |
|
|
72
|
+
| `mount_point` | `secret` | KV v2 secrets-engine mount |
|
|
73
|
+
| `auth_method` | `token` | `token` or `approle` |
|
|
74
|
+
| `token_env` | `VAULT_TOKEN` | Env var with the token (token auth) |
|
|
75
|
+
| `role_id_env` | `VAULT_ROLE_ID`| Env var with the role_id (approle) |
|
|
76
|
+
| `secret_id_env` | `VAULT_SECRET_ID` | Env var with the secret_id (approle) |
|
|
77
|
+
| `namespace` | `""` | Vault Enterprise namespace |
|
|
78
|
+
| `verify` | `true` | TLS verify: `true`/`false` or a CA-bundle path |
|
|
79
|
+
| `cache_ttl_seconds` | `300` | Disk+memory cache TTL; `0` disables caching |
|
|
80
|
+
| `request_timeout_seconds` | `30` | Per-request Vault HTTP timeout |
|
|
81
|
+
| `override_existing` | `true` | Vault values overwrite existing env values |
|
|
82
|
+
|
|
83
|
+
## How it behaves
|
|
84
|
+
|
|
85
|
+
* **Never raises.** A misconfigured or unreachable Vault produces a clean
|
|
86
|
+
`FetchResult` with an `ErrorKind` (`not_configured`, `auth_failed`,
|
|
87
|
+
`ref_invalid`, `network`, `timeout`, ...), never a startup crash.
|
|
88
|
+
* **Protects its own credentials.** The auth env vars (`VAULT_ADDR`,
|
|
89
|
+
`VAULT_TOKEN`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`) are marked protected, so a
|
|
90
|
+
secret stored in Vault cannot clobber the credential used to reach Vault.
|
|
91
|
+
* **Caches values, never tokens.** The optional on-disk cache stores only the
|
|
92
|
+
resolved secret values; the token/secret_id are SHA-256 fingerprinted before
|
|
93
|
+
they touch the cache key.
|
|
94
|
+
* **Skips non-env-safe keys.** A KV key that is not a valid environment
|
|
95
|
+
variable name is skipped with a warning rather than injected.
|
|
96
|
+
|
|
97
|
+
## Threat model
|
|
98
|
+
|
|
99
|
+
This plugin runs on the Hermes startup path with access to Vault credentials, so
|
|
100
|
+
its security posture is deliberately narrow. Every protection below is already
|
|
101
|
+
implemented; each maps to a concrete mechanism in the source.
|
|
102
|
+
|
|
103
|
+
### Startup cannot be crashed by this source
|
|
104
|
+
|
|
105
|
+
`fetch()` never raises. Every configuration problem returns a `FetchResult` with
|
|
106
|
+
a machine-readable `ErrorKind` (`not_configured`, `auth_failed`, `ref_invalid`,
|
|
107
|
+
`network`, `timeout`, `binary_missing`, `internal`), and the backend in
|
|
108
|
+
`_client.py` only ever raises `RuntimeError`, which `fetch()` catches and
|
|
109
|
+
classifies. A misconfigured, unreachable, or unauthenticated Vault degrades to a
|
|
110
|
+
clean skip, never a traceback on the non-interactive startup path.
|
|
111
|
+
|
|
112
|
+
### A Vault secret cannot hijack the credential used to reach Vault
|
|
113
|
+
|
|
114
|
+
`protected_env_vars()` marks the auth env vars (`VAULT_ADDR`, `VAULT_TOKEN`,
|
|
115
|
+
`VAULT_ROLE_ID`, `VAULT_SECRET_ID`, or their configured overrides) as protected.
|
|
116
|
+
The orchestrator will not overwrite a protected variable, so a key that happens
|
|
117
|
+
to be named `VAULT_TOKEN` inside the Vault payload cannot clobber the token the
|
|
118
|
+
process is already using to authenticate. Names that are not valid env-var
|
|
119
|
+
identifiers are dropped from the protected set, so a misconfigured section cannot
|
|
120
|
+
silently weaken it.
|
|
121
|
+
|
|
122
|
+
### Tokens never reach the on-disk cache in the clear
|
|
123
|
+
|
|
124
|
+
The optional two-layer cache stores only resolved secret values. The Vault token
|
|
125
|
+
(or the AppRole `role_id` and `secret_id`) is SHA-256 fingerprinted by
|
|
126
|
+
`_fingerprint()` before it becomes part of the cache key, so neither the in-memory
|
|
127
|
+
key nor the `vault_cache.json` file on disk contains the raw credential. Caching
|
|
128
|
+
is opt-out: `cache_ttl_seconds: 0` disables it entirely.
|
|
129
|
+
|
|
130
|
+
### Only environment-safe keys are injected
|
|
131
|
+
|
|
132
|
+
Each KV v2 key is validated with the host's `is_valid_env_name()` before it is
|
|
133
|
+
contributed. A key that is not a valid environment-variable name is skipped with
|
|
134
|
+
a warning instead of being injected, so a stray Vault key cannot produce a
|
|
135
|
+
malformed process environment.
|
|
136
|
+
|
|
137
|
+
### TLS verification is on by default
|
|
138
|
+
|
|
139
|
+
`verify` defaults to `true`. It is disabled only when the operator explicitly
|
|
140
|
+
sets a false-y value, and a string value is treated as a CA-bundle path. TLS
|
|
141
|
+
therefore fails closed rather than open.
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
Requires a `hermes-agent` source checkout (for the `agent.*` packages and the
|
|
146
|
+
conformance kit at `tests/secret_sources/conformance.py`).
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# host = hermes-agent checkout, plugin = this repo. Put the host on PYTHONPATH so
|
|
150
|
+
# `import tests` resolves to the host conformance package. This repo keeps its own
|
|
151
|
+
# tests in `checks/` (not `tests/`) precisely so it cannot shadow that package.
|
|
152
|
+
PYTHONPATH="/path/to/hermes-agent:$(pwd)" pytest
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`checks/test_conformance.py` runs the host `SecretSourceConformance` contract;
|
|
156
|
+
`checks/test_vault_source.py` covers behavior with `hvac` mocked (no network, no
|
|
157
|
+
Vault binary needed).
|
|
158
|
+
|
|
159
|
+
To run against a real dev server:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
vault server -dev # prints VAULT_ADDR + a root token
|
|
163
|
+
export VAULT_ADDR=http://127.0.0.1:8200
|
|
164
|
+
export VAULT_TOKEN=<root-token-from-output>
|
|
165
|
+
vault kv put secret/apps/my-agent API_KEY=abc DB_URL=postgres://x
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""HashiCorp Vault as a Hermes Agent secret source.
|
|
2
|
+
|
|
3
|
+
Bulk KV v2 source: reads one Vault KV v2 secret path and contributes its
|
|
4
|
+
keys as environment variables at Hermes startup, mirroring how the bundled
|
|
5
|
+
Bitwarden Secrets Manager source injects a whole project.
|
|
6
|
+
|
|
7
|
+
Registered through the ``hermes_agent.plugins`` entry point (see
|
|
8
|
+
pyproject.toml): Hermes' plugin loader imports this package and calls
|
|
9
|
+
``register(ctx)``.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import FrozenSet
|
|
16
|
+
|
|
17
|
+
from agent.secret_sources.base import (
|
|
18
|
+
ErrorKind,
|
|
19
|
+
FetchResult,
|
|
20
|
+
SecretSource,
|
|
21
|
+
is_valid_env_name,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from . import _client
|
|
25
|
+
|
|
26
|
+
__all__ = ["VaultSource", "register"]
|
|
27
|
+
|
|
28
|
+
# The following are NAMES of environment variables that hold Vault credentials,
|
|
29
|
+
# not secret values themselves; bandit B105 flags the "TOKEN"/"SECRET" literals.
|
|
30
|
+
_DEFAULT_ADDR_ENV = "VAULT_ADDR"
|
|
31
|
+
_DEFAULT_TOKEN_ENV = "VAULT_TOKEN" # nosec B105
|
|
32
|
+
_DEFAULT_ROLE_ID_ENV = "VAULT_ROLE_ID"
|
|
33
|
+
_DEFAULT_SECRET_ID_ENV = "VAULT_SECRET_ID" # nosec B105
|
|
34
|
+
_DEFAULT_MOUNT_POINT = "secret"
|
|
35
|
+
_DEFAULT_CACHE_TTL_SECONDS = 300.0
|
|
36
|
+
_DEFAULT_REQUEST_TIMEOUT_SECONDS = 30.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class VaultSource(SecretSource):
|
|
40
|
+
"""HashiCorp Vault KV v2 as a registered secret source.
|
|
41
|
+
|
|
42
|
+
Thin adapter over :func:`_client.fetch_vault_secrets`. ``fetch()`` only
|
|
43
|
+
resolves values; precedence, override semantics, conflict warnings and
|
|
44
|
+
the actual ``os.environ`` writes belong to the orchestrator
|
|
45
|
+
(``agent.secret_sources.registry.apply_all``).
|
|
46
|
+
|
|
47
|
+
Vault is a **bulk** source: it injects every key found at the configured
|
|
48
|
+
KV v2 path, so explicit per-var bindings from mapped sources outrank it.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
name = "vault"
|
|
52
|
+
label = "HashiCorp Vault"
|
|
53
|
+
shape = "bulk"
|
|
54
|
+
scheme = "vault"
|
|
55
|
+
|
|
56
|
+
def override_existing(self, cfg: dict) -> bool:
|
|
57
|
+
# Default True: the point of a central vault is rotation. If .env had
|
|
58
|
+
# the final say, rotating a key in Vault would not take effect until
|
|
59
|
+
# the stale .env line was also removed.
|
|
60
|
+
return bool(isinstance(cfg, dict) and cfg.get("override_existing", True))
|
|
61
|
+
|
|
62
|
+
def protected_env_vars(self, cfg: dict) -> FrozenSet[str]:
|
|
63
|
+
cfg = cfg if isinstance(cfg, dict) else {}
|
|
64
|
+
names = {
|
|
65
|
+
str(cfg.get("addr_env") or _DEFAULT_ADDR_ENV),
|
|
66
|
+
str(cfg.get("token_env") or _DEFAULT_TOKEN_ENV),
|
|
67
|
+
str(cfg.get("role_id_env") or _DEFAULT_ROLE_ID_ENV),
|
|
68
|
+
str(cfg.get("secret_id_env") or _DEFAULT_SECRET_ID_ENV),
|
|
69
|
+
}
|
|
70
|
+
# A vault that stores its own VAULT_TOKEN must never clobber the
|
|
71
|
+
# credential used to reach it. The orchestrator also requires valid
|
|
72
|
+
# env-var names, so drop anything a misconfigured section produced.
|
|
73
|
+
return frozenset(n for n in names if is_valid_env_name(n))
|
|
74
|
+
|
|
75
|
+
def config_schema(self) -> dict:
|
|
76
|
+
return {
|
|
77
|
+
"enabled": {"description": "Master switch", "default": False},
|
|
78
|
+
"addr": {
|
|
79
|
+
"description": "Vault server URL (overrides addr_env when set)",
|
|
80
|
+
"default": "",
|
|
81
|
+
},
|
|
82
|
+
"addr_env": {
|
|
83
|
+
"description": "Env var holding the Vault server URL",
|
|
84
|
+
"default": _DEFAULT_ADDR_ENV,
|
|
85
|
+
},
|
|
86
|
+
"path": {
|
|
87
|
+
"description": "KV v2 secret path whose keys become env vars",
|
|
88
|
+
"default": "",
|
|
89
|
+
},
|
|
90
|
+
"mount_point": {
|
|
91
|
+
"description": "KV v2 secrets-engine mount point",
|
|
92
|
+
"default": _DEFAULT_MOUNT_POINT,
|
|
93
|
+
},
|
|
94
|
+
"auth_method": {
|
|
95
|
+
"description": "How to authenticate: 'token' or 'approle'",
|
|
96
|
+
"default": "token",
|
|
97
|
+
},
|
|
98
|
+
"token_env": {
|
|
99
|
+
"description": "Env var holding the Vault token (auth_method=token)",
|
|
100
|
+
"default": _DEFAULT_TOKEN_ENV,
|
|
101
|
+
},
|
|
102
|
+
"role_id_env": {
|
|
103
|
+
"description": "Env var holding the AppRole role_id (auth_method=approle)",
|
|
104
|
+
"default": _DEFAULT_ROLE_ID_ENV,
|
|
105
|
+
},
|
|
106
|
+
"secret_id_env": {
|
|
107
|
+
"description": "Env var holding the AppRole secret_id (auth_method=approle)",
|
|
108
|
+
"default": _DEFAULT_SECRET_ID_ENV,
|
|
109
|
+
},
|
|
110
|
+
"namespace": {
|
|
111
|
+
"description": "Vault Enterprise namespace (empty for OSS)",
|
|
112
|
+
"default": "",
|
|
113
|
+
},
|
|
114
|
+
"verify": {
|
|
115
|
+
"description": "TLS verify: true/false or a CA-bundle path",
|
|
116
|
+
"default": True,
|
|
117
|
+
},
|
|
118
|
+
"cache_ttl_seconds": {
|
|
119
|
+
"description": "Disk+memory cache TTL; 0 disables caching",
|
|
120
|
+
"default": 300,
|
|
121
|
+
},
|
|
122
|
+
"request_timeout_seconds": {
|
|
123
|
+
"description": "Per-request Vault HTTP timeout in seconds",
|
|
124
|
+
"default": 30,
|
|
125
|
+
},
|
|
126
|
+
"override_existing": {
|
|
127
|
+
"description": "Vault values overwrite .env/shell values",
|
|
128
|
+
"default": True,
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
|
|
133
|
+
cfg = cfg if isinstance(cfg, dict) else {}
|
|
134
|
+
result = FetchResult()
|
|
135
|
+
|
|
136
|
+
addr_env = str(cfg.get("addr_env") or _DEFAULT_ADDR_ENV)
|
|
137
|
+
addr = str(cfg.get("addr") or os.environ.get(addr_env, "") or "").strip()
|
|
138
|
+
if not addr:
|
|
139
|
+
result.error = (
|
|
140
|
+
"secrets.vault is enabled but no Vault address is set "
|
|
141
|
+
f"(set {addr_env} or secrets.vault.addr)."
|
|
142
|
+
)
|
|
143
|
+
result.error_kind = ErrorKind.NOT_CONFIGURED
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
path = str(cfg.get("path") or "").strip()
|
|
147
|
+
if not path:
|
|
148
|
+
result.error = (
|
|
149
|
+
"secrets.vault.path is empty (the KV v2 path whose keys are "
|
|
150
|
+
"injected as environment variables)."
|
|
151
|
+
)
|
|
152
|
+
result.error_kind = ErrorKind.NOT_CONFIGURED
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
auth_method = str(cfg.get("auth_method") or "token").strip().lower()
|
|
156
|
+
# Empty-string initialisation, not a hardcoded secret (bandit B105).
|
|
157
|
+
token = role_id = secret_id = "" # nosec B105
|
|
158
|
+
if auth_method == "token":
|
|
159
|
+
token_env = str(cfg.get("token_env") or _DEFAULT_TOKEN_ENV)
|
|
160
|
+
token = os.environ.get(token_env, "").strip()
|
|
161
|
+
if not token:
|
|
162
|
+
result.error = (
|
|
163
|
+
f"secrets.vault auth_method is 'token' but {token_env} is not set."
|
|
164
|
+
)
|
|
165
|
+
result.error_kind = ErrorKind.NOT_CONFIGURED
|
|
166
|
+
return result
|
|
167
|
+
elif auth_method == "approle":
|
|
168
|
+
role_id_env = str(cfg.get("role_id_env") or _DEFAULT_ROLE_ID_ENV)
|
|
169
|
+
secret_id_env = str(cfg.get("secret_id_env") or _DEFAULT_SECRET_ID_ENV)
|
|
170
|
+
role_id = os.environ.get(role_id_env, "").strip()
|
|
171
|
+
secret_id = os.environ.get(secret_id_env, "").strip()
|
|
172
|
+
if not role_id or not secret_id:
|
|
173
|
+
result.error = (
|
|
174
|
+
"secrets.vault auth_method is 'approle' but "
|
|
175
|
+
f"{role_id_env} and {secret_id_env} are not both set."
|
|
176
|
+
)
|
|
177
|
+
result.error_kind = ErrorKind.NOT_CONFIGURED
|
|
178
|
+
return result
|
|
179
|
+
else:
|
|
180
|
+
result.error = (
|
|
181
|
+
f"secrets.vault.auth_method {auth_method!r} is not supported "
|
|
182
|
+
"(use 'token' or 'approle')."
|
|
183
|
+
)
|
|
184
|
+
result.error_kind = ErrorKind.NOT_CONFIGURED
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
mount_point = (
|
|
188
|
+
str(cfg.get("mount_point") or _DEFAULT_MOUNT_POINT).strip()
|
|
189
|
+
or _DEFAULT_MOUNT_POINT
|
|
190
|
+
)
|
|
191
|
+
namespace = str(cfg.get("namespace") or "").strip() or None
|
|
192
|
+
verify = _coerce_verify(cfg.get("verify", True))
|
|
193
|
+
ttl = _coerce_float(
|
|
194
|
+
cfg.get("cache_ttl_seconds", _DEFAULT_CACHE_TTL_SECONDS),
|
|
195
|
+
_DEFAULT_CACHE_TTL_SECONDS,
|
|
196
|
+
allow_zero=True,
|
|
197
|
+
)
|
|
198
|
+
timeout = _coerce_float(
|
|
199
|
+
cfg.get("request_timeout_seconds", _DEFAULT_REQUEST_TIMEOUT_SECONDS),
|
|
200
|
+
_DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
|
201
|
+
allow_zero=False,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
secrets, warnings = _client.fetch_vault_secrets(
|
|
206
|
+
addr=addr,
|
|
207
|
+
path=path,
|
|
208
|
+
mount_point=mount_point,
|
|
209
|
+
namespace=namespace,
|
|
210
|
+
verify=verify,
|
|
211
|
+
timeout=timeout,
|
|
212
|
+
auth_method=auth_method,
|
|
213
|
+
token=token,
|
|
214
|
+
role_id=role_id,
|
|
215
|
+
secret_id=secret_id,
|
|
216
|
+
cache_ttl_seconds=ttl,
|
|
217
|
+
home_path=home_path,
|
|
218
|
+
)
|
|
219
|
+
except RuntimeError as exc:
|
|
220
|
+
result.error = str(exc)
|
|
221
|
+
result.error_kind = _classify_vault_error(str(exc))
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
result.secrets = secrets
|
|
225
|
+
result.warnings.extend(warnings)
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _coerce_float(value, default: float, *, allow_zero: bool) -> float:
|
|
230
|
+
try:
|
|
231
|
+
out = float(value)
|
|
232
|
+
except (TypeError, ValueError):
|
|
233
|
+
return default
|
|
234
|
+
if out < 0 or (out == 0 and not allow_zero):
|
|
235
|
+
return default
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _coerce_verify(value):
|
|
240
|
+
if isinstance(value, bool):
|
|
241
|
+
return value
|
|
242
|
+
if isinstance(value, str):
|
|
243
|
+
low = value.strip().lower()
|
|
244
|
+
if low in ("false", "0", "no", "off"):
|
|
245
|
+
return False
|
|
246
|
+
if low in ("true", "1", "yes", "on"):
|
|
247
|
+
return True
|
|
248
|
+
return value.strip() # otherwise treat it as a CA-bundle path
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _classify_vault_error(message: str) -> ErrorKind:
|
|
253
|
+
"""Best-effort mapping of a backend failure onto the shared taxonomy."""
|
|
254
|
+
lowered = message.lower()
|
|
255
|
+
if "not installed" in lowered:
|
|
256
|
+
return ErrorKind.BINARY_MISSING
|
|
257
|
+
if "timed out" in lowered or "timeout" in lowered:
|
|
258
|
+
return ErrorKind.TIMEOUT
|
|
259
|
+
if any(tok in lowered for tok in (
|
|
260
|
+
"forbidden", "permission denied", "invalid request",
|
|
261
|
+
"credentials rejected", "401", "403",
|
|
262
|
+
)):
|
|
263
|
+
return ErrorKind.AUTH_FAILED
|
|
264
|
+
if any(tok in lowered for tok in ("invalidpath", "not found", "404")):
|
|
265
|
+
return ErrorKind.REF_INVALID
|
|
266
|
+
if any(tok in lowered for tok in (
|
|
267
|
+
"connection", "network", "refused", "unreachable",
|
|
268
|
+
"resolve", "dns", "failed to create",
|
|
269
|
+
)):
|
|
270
|
+
return ErrorKind.NETWORK
|
|
271
|
+
return ErrorKind.INTERNAL
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def register(ctx) -> None:
|
|
275
|
+
"""Entry point invoked by Hermes' plugin loader.
|
|
276
|
+
|
|
277
|
+
``ctx`` is the PluginContext; it validates the instance and hands it to
|
|
278
|
+
the secret-source registry.
|
|
279
|
+
"""
|
|
280
|
+
ctx.register_secret_source(VaultSource())
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""HashiCorp Vault backend: fetch + two-layer cache (hvac, KV v2, bulk).
|
|
2
|
+
|
|
3
|
+
Mirrors the security posture of the bundled Bitwarden/1Password sources:
|
|
4
|
+
this module only *reads* secrets and raises ``RuntimeError`` on any failure.
|
|
5
|
+
The :class:`~hermes_vault_secret_source.VaultSource` adapter turns that into
|
|
6
|
+
a ``FetchResult`` so nothing ever escapes onto the Hermes startup path.
|
|
7
|
+
|
|
8
|
+
``hvac`` is imported lazily inside :func:`_read_kv2` so that importing this
|
|
9
|
+
module (and running the conformance suite) never requires the dependency;
|
|
10
|
+
only an actually-configured fetch touches Vault.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
from agent.secret_sources.base import is_valid_env_name
|
|
20
|
+
|
|
21
|
+
# The shared on-disk cache substrate is re-exported by the host for backends.
|
|
22
|
+
# If a host ships without it we degrade to no caching rather than fail import.
|
|
23
|
+
try:
|
|
24
|
+
from agent.secret_sources._cache import CachedFetch, DiskCache
|
|
25
|
+
except Exception: # pragma: no cover - only on an incompatible host
|
|
26
|
+
CachedFetch = None # type: ignore[assignment,misc]
|
|
27
|
+
DiskCache = None # type: ignore[assignment,misc]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _serialize_cache_key(key) -> str:
|
|
31
|
+
return "|".join(str(part) for part in key)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_CACHE: Dict[tuple, "CachedFetch"] = {}
|
|
35
|
+
_DISK_CACHE = (
|
|
36
|
+
DiskCache("vault_cache.json", key_serializer=_serialize_cache_key)
|
|
37
|
+
if DiskCache is not None
|
|
38
|
+
else None
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fingerprint(value: str) -> str:
|
|
43
|
+
"""SHA-256 prefix of auth material, used only inside cache keys.
|
|
44
|
+
|
|
45
|
+
The on-disk cache stores secret *values*; the token/secret_id must never
|
|
46
|
+
reach it in the clear, so it is fingerprinted before it enters the key.
|
|
47
|
+
"""
|
|
48
|
+
return hashlib.sha256((value or "").encode("utf-8")).hexdigest()[:16]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fetch_vault_secrets(
|
|
52
|
+
*,
|
|
53
|
+
addr: str,
|
|
54
|
+
path: str,
|
|
55
|
+
mount_point: str = "secret",
|
|
56
|
+
namespace: Optional[str] = None,
|
|
57
|
+
verify=True,
|
|
58
|
+
timeout: float = 30.0,
|
|
59
|
+
auth_method: str = "token",
|
|
60
|
+
token: str = "",
|
|
61
|
+
role_id: str = "",
|
|
62
|
+
secret_id: str = "",
|
|
63
|
+
cache_ttl_seconds: float = 300.0,
|
|
64
|
+
use_cache: bool = True,
|
|
65
|
+
home_path: Optional[Path] = None,
|
|
66
|
+
) -> Tuple[Dict[str, str], List[str]]:
|
|
67
|
+
"""Read one KV v2 path and return ``(env_name -> value, warnings)``.
|
|
68
|
+
|
|
69
|
+
Raises ``RuntimeError`` on any fatal condition (hvac missing, auth
|
|
70
|
+
failure, network error, bad path, unexpected shape). The caller in
|
|
71
|
+
``__init__.py`` catches this and classifies it onto an ``ErrorKind``.
|
|
72
|
+
"""
|
|
73
|
+
if auth_method == "approle":
|
|
74
|
+
auth_fp = _fingerprint("approle:" + role_id + ":" + secret_id)
|
|
75
|
+
else:
|
|
76
|
+
auth_fp = _fingerprint("token:" + token)
|
|
77
|
+
cache_key = (auth_fp, addr, mount_point, path, namespace or "")
|
|
78
|
+
|
|
79
|
+
if use_cache and CachedFetch is not None:
|
|
80
|
+
cached = _CACHE.get(cache_key)
|
|
81
|
+
if cached and cached.is_fresh(cache_ttl_seconds):
|
|
82
|
+
return dict(cached.secrets), []
|
|
83
|
+
if _DISK_CACHE is not None:
|
|
84
|
+
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
|
|
85
|
+
if disk_cached is not None:
|
|
86
|
+
_CACHE[cache_key] = disk_cached
|
|
87
|
+
return dict(disk_cached.secrets), []
|
|
88
|
+
|
|
89
|
+
secrets, warnings = _read_kv2(
|
|
90
|
+
addr=addr,
|
|
91
|
+
path=path,
|
|
92
|
+
mount_point=mount_point,
|
|
93
|
+
namespace=namespace,
|
|
94
|
+
verify=verify,
|
|
95
|
+
timeout=timeout,
|
|
96
|
+
auth_method=auth_method,
|
|
97
|
+
token=token,
|
|
98
|
+
role_id=role_id,
|
|
99
|
+
secret_id=secret_id,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if use_cache and CachedFetch is not None:
|
|
103
|
+
entry = CachedFetch(secrets=secrets, fetched_at=time.time())
|
|
104
|
+
_CACHE[cache_key] = entry
|
|
105
|
+
if _DISK_CACHE is not None:
|
|
106
|
+
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
|
|
107
|
+
return secrets, warnings
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_kv2(
|
|
111
|
+
*,
|
|
112
|
+
addr,
|
|
113
|
+
path,
|
|
114
|
+
mount_point,
|
|
115
|
+
namespace,
|
|
116
|
+
verify,
|
|
117
|
+
timeout,
|
|
118
|
+
auth_method,
|
|
119
|
+
token,
|
|
120
|
+
role_id,
|
|
121
|
+
secret_id,
|
|
122
|
+
) -> Tuple[Dict[str, str], List[str]]:
|
|
123
|
+
try:
|
|
124
|
+
import hvac
|
|
125
|
+
from hvac import exceptions as hvac_exceptions
|
|
126
|
+
except ImportError as exc:
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
"hvac is not installed. Run `pip install hvac` in the Hermes "
|
|
129
|
+
"environment to use the Vault secret source."
|
|
130
|
+
) from exc
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
client = hvac.Client(
|
|
134
|
+
url=addr, verify=verify, timeout=timeout, namespace=namespace,
|
|
135
|
+
)
|
|
136
|
+
except Exception as exc: # defensive: never let client init escape
|
|
137
|
+
raise RuntimeError(
|
|
138
|
+
_transport_message(f"failed to create Vault client for {addr}", exc)
|
|
139
|
+
) from exc
|
|
140
|
+
|
|
141
|
+
# -- authenticate ---------------------------------------------------
|
|
142
|
+
try:
|
|
143
|
+
if auth_method == "approle":
|
|
144
|
+
client.auth.approle.login(role_id=role_id, secret_id=secret_id)
|
|
145
|
+
else:
|
|
146
|
+
client.token = token
|
|
147
|
+
authed = client.is_authenticated()
|
|
148
|
+
except hvac_exceptions.Forbidden as exc:
|
|
149
|
+
raise RuntimeError(f"Vault authentication forbidden: {exc}") from exc
|
|
150
|
+
except hvac_exceptions.InvalidRequest as exc:
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
f"Vault authentication failed (invalid request): {exc}"
|
|
153
|
+
) from exc
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
raise RuntimeError(_transport_message("Vault authentication", exc)) from exc
|
|
156
|
+
if not authed:
|
|
157
|
+
raise RuntimeError(
|
|
158
|
+
"Vault authentication failed: credentials rejected "
|
|
159
|
+
"(is_authenticated() returned False)."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# -- read the KV v2 path -------------------------------------------
|
|
163
|
+
try:
|
|
164
|
+
resp = client.secrets.kv.v2.read_secret_version(
|
|
165
|
+
path=path, mount_point=mount_point, raise_on_deleted_version=True,
|
|
166
|
+
)
|
|
167
|
+
except hvac_exceptions.InvalidPath as exc:
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
f"Vault path not found (invalidpath): {mount_point}/{path}: {exc}"
|
|
170
|
+
) from exc
|
|
171
|
+
except hvac_exceptions.Forbidden as exc:
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
f"Vault read forbidden for {mount_point}/{path}: {exc}"
|
|
174
|
+
) from exc
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
raise RuntimeError(
|
|
177
|
+
_transport_message(f"Vault read for {mount_point}/{path}", exc)
|
|
178
|
+
) from exc
|
|
179
|
+
|
|
180
|
+
data = None
|
|
181
|
+
if isinstance(resp, dict):
|
|
182
|
+
inner = resp.get("data")
|
|
183
|
+
if isinstance(inner, dict):
|
|
184
|
+
data = inner.get("data")
|
|
185
|
+
if not isinstance(data, dict):
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
"Vault returned an unexpected response shape (data.data missing)."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
secrets: Dict[str, str] = {}
|
|
191
|
+
warnings: List[str] = []
|
|
192
|
+
for key, value in data.items():
|
|
193
|
+
if not isinstance(key, str) or value is None:
|
|
194
|
+
continue
|
|
195
|
+
str_value = value if isinstance(value, str) else str(value)
|
|
196
|
+
if not is_valid_env_name(key):
|
|
197
|
+
warnings.append(f"Skipping secret {key!r}: not a valid env-var name")
|
|
198
|
+
continue
|
|
199
|
+
secrets[key] = str_value
|
|
200
|
+
return secrets, warnings
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _transport_message(context: str, exc: Exception) -> str:
|
|
204
|
+
"""Build a RuntimeError message that ``_classify_vault_error`` can bucket."""
|
|
205
|
+
name = type(exc).__name__.lower()
|
|
206
|
+
text = str(exc).lower()
|
|
207
|
+
if "timeout" in name or "timed out" in text:
|
|
208
|
+
return f"{context} timed out: {exc}"
|
|
209
|
+
if "connection" in name or "connection" in text or "refused" in text:
|
|
210
|
+
return f"{context} connection error: {exc}"
|
|
211
|
+
return f"{context} failed: {exc}"
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hermes-vault-secret-source
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HashiCorp Vault secret source for Hermes Agent (bulk KV v2).
|
|
5
|
+
Author: cryptoyasenka
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/cryptoyasenka/hermes-vault-secret-source
|
|
8
|
+
Project-URL: Repository, https://github.com/cryptoyasenka/hermes-vault-secret-source
|
|
9
|
+
Project-URL: Issues, https://github.com/cryptoyasenka/hermes-vault-secret-source/issues
|
|
10
|
+
Keywords: hermes,hermes-agent,vault,hashicorp,secrets,plugin
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: hvac>=2.0
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# hermes-vault-secret-source
|
|
30
|
+
|
|
31
|
+
[](https://github.com/cryptoyasenka/hermes-vault-secret-source/actions/workflows/ci.yml)
|
|
32
|
+
|
|
33
|
+
A [HashiCorp Vault](https://developer.hashicorp.com/vault) secret source for
|
|
34
|
+
[Hermes Agent](https://github.com/NousResearch/hermes-agent), packaged as a
|
|
35
|
+
standalone pip plugin.
|
|
36
|
+
|
|
37
|
+
At Hermes startup it reads one Vault **KV v2** secret path and injects every
|
|
38
|
+
key found there as an environment variable, the same way the bundled Bitwarden
|
|
39
|
+
Secrets Manager source injects a whole project. This is a **bulk** source, so
|
|
40
|
+
explicit per-variable bindings from mapped sources (like 1Password) take
|
|
41
|
+
precedence over it.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install "hermes-vault-secret-source @ git+https://github.com/cryptoyasenka/hermes-vault-secret-source"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The package is not on PyPI yet, so install it straight from the repository as
|
|
50
|
+
shown above. Once it is published, this simplifies to:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install hermes-vault-secret-source
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Either way this pulls in [`hvac`](https://hvac.readthedocs.io/), the official
|
|
57
|
+
Python client for Vault. Install it into the same environment as `hermes-agent`.
|
|
58
|
+
|
|
59
|
+
Hermes discovers the plugin automatically through the `hermes_agent.plugins`
|
|
60
|
+
entry point, but plugins are opt-in. Enable it once:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
hermes plugins enable hermes-hashicorp-vault
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Configure
|
|
67
|
+
|
|
68
|
+
Add a `vault` block under `secrets` in your Hermes config:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
secrets:
|
|
72
|
+
vault:
|
|
73
|
+
enabled: true
|
|
74
|
+
# addr is read from $VAULT_ADDR by default; set `addr` to hard-code it.
|
|
75
|
+
path: apps/my-agent # KV v2 path whose keys become env vars
|
|
76
|
+
mount_point: secret # KV v2 mount (default: secret)
|
|
77
|
+
auth_method: token # "token" (default) or "approle"
|
|
78
|
+
cache_ttl_seconds: 300 # 0 disables caching
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Credentials are never written into config; they come from environment
|
|
82
|
+
variables:
|
|
83
|
+
|
|
84
|
+
| Auth method | Env vars used |
|
|
85
|
+
|-------------|--------------------------------------------------|
|
|
86
|
+
| `token` | `VAULT_ADDR`, `VAULT_TOKEN` |
|
|
87
|
+
| `approle` | `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID` |
|
|
88
|
+
|
|
89
|
+
The env-var names are overridable per field (`addr_env`, `token_env`,
|
|
90
|
+
`role_id_env`, `secret_id_env`) if your deployment uses different names.
|
|
91
|
+
|
|
92
|
+
### All config keys
|
|
93
|
+
|
|
94
|
+
| Key | Default | Meaning |
|
|
95
|
+
|---------------------------|----------------|-------------------------------------------------|
|
|
96
|
+
| `enabled` | `false` | Master switch |
|
|
97
|
+
| `addr` | `""` | Vault URL; overrides `addr_env` when set |
|
|
98
|
+
| `addr_env` | `VAULT_ADDR` | Env var holding the Vault URL |
|
|
99
|
+
| `path` | `""` | KV v2 path to read (required) |
|
|
100
|
+
| `mount_point` | `secret` | KV v2 secrets-engine mount |
|
|
101
|
+
| `auth_method` | `token` | `token` or `approle` |
|
|
102
|
+
| `token_env` | `VAULT_TOKEN` | Env var with the token (token auth) |
|
|
103
|
+
| `role_id_env` | `VAULT_ROLE_ID`| Env var with the role_id (approle) |
|
|
104
|
+
| `secret_id_env` | `VAULT_SECRET_ID` | Env var with the secret_id (approle) |
|
|
105
|
+
| `namespace` | `""` | Vault Enterprise namespace |
|
|
106
|
+
| `verify` | `true` | TLS verify: `true`/`false` or a CA-bundle path |
|
|
107
|
+
| `cache_ttl_seconds` | `300` | Disk+memory cache TTL; `0` disables caching |
|
|
108
|
+
| `request_timeout_seconds` | `30` | Per-request Vault HTTP timeout |
|
|
109
|
+
| `override_existing` | `true` | Vault values overwrite existing env values |
|
|
110
|
+
|
|
111
|
+
## How it behaves
|
|
112
|
+
|
|
113
|
+
* **Never raises.** A misconfigured or unreachable Vault produces a clean
|
|
114
|
+
`FetchResult` with an `ErrorKind` (`not_configured`, `auth_failed`,
|
|
115
|
+
`ref_invalid`, `network`, `timeout`, ...), never a startup crash.
|
|
116
|
+
* **Protects its own credentials.** The auth env vars (`VAULT_ADDR`,
|
|
117
|
+
`VAULT_TOKEN`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`) are marked protected, so a
|
|
118
|
+
secret stored in Vault cannot clobber the credential used to reach Vault.
|
|
119
|
+
* **Caches values, never tokens.** The optional on-disk cache stores only the
|
|
120
|
+
resolved secret values; the token/secret_id are SHA-256 fingerprinted before
|
|
121
|
+
they touch the cache key.
|
|
122
|
+
* **Skips non-env-safe keys.** A KV key that is not a valid environment
|
|
123
|
+
variable name is skipped with a warning rather than injected.
|
|
124
|
+
|
|
125
|
+
## Threat model
|
|
126
|
+
|
|
127
|
+
This plugin runs on the Hermes startup path with access to Vault credentials, so
|
|
128
|
+
its security posture is deliberately narrow. Every protection below is already
|
|
129
|
+
implemented; each maps to a concrete mechanism in the source.
|
|
130
|
+
|
|
131
|
+
### Startup cannot be crashed by this source
|
|
132
|
+
|
|
133
|
+
`fetch()` never raises. Every configuration problem returns a `FetchResult` with
|
|
134
|
+
a machine-readable `ErrorKind` (`not_configured`, `auth_failed`, `ref_invalid`,
|
|
135
|
+
`network`, `timeout`, `binary_missing`, `internal`), and the backend in
|
|
136
|
+
`_client.py` only ever raises `RuntimeError`, which `fetch()` catches and
|
|
137
|
+
classifies. A misconfigured, unreachable, or unauthenticated Vault degrades to a
|
|
138
|
+
clean skip, never a traceback on the non-interactive startup path.
|
|
139
|
+
|
|
140
|
+
### A Vault secret cannot hijack the credential used to reach Vault
|
|
141
|
+
|
|
142
|
+
`protected_env_vars()` marks the auth env vars (`VAULT_ADDR`, `VAULT_TOKEN`,
|
|
143
|
+
`VAULT_ROLE_ID`, `VAULT_SECRET_ID`, or their configured overrides) as protected.
|
|
144
|
+
The orchestrator will not overwrite a protected variable, so a key that happens
|
|
145
|
+
to be named `VAULT_TOKEN` inside the Vault payload cannot clobber the token the
|
|
146
|
+
process is already using to authenticate. Names that are not valid env-var
|
|
147
|
+
identifiers are dropped from the protected set, so a misconfigured section cannot
|
|
148
|
+
silently weaken it.
|
|
149
|
+
|
|
150
|
+
### Tokens never reach the on-disk cache in the clear
|
|
151
|
+
|
|
152
|
+
The optional two-layer cache stores only resolved secret values. The Vault token
|
|
153
|
+
(or the AppRole `role_id` and `secret_id`) is SHA-256 fingerprinted by
|
|
154
|
+
`_fingerprint()` before it becomes part of the cache key, so neither the in-memory
|
|
155
|
+
key nor the `vault_cache.json` file on disk contains the raw credential. Caching
|
|
156
|
+
is opt-out: `cache_ttl_seconds: 0` disables it entirely.
|
|
157
|
+
|
|
158
|
+
### Only environment-safe keys are injected
|
|
159
|
+
|
|
160
|
+
Each KV v2 key is validated with the host's `is_valid_env_name()` before it is
|
|
161
|
+
contributed. A key that is not a valid environment-variable name is skipped with
|
|
162
|
+
a warning instead of being injected, so a stray Vault key cannot produce a
|
|
163
|
+
malformed process environment.
|
|
164
|
+
|
|
165
|
+
### TLS verification is on by default
|
|
166
|
+
|
|
167
|
+
`verify` defaults to `true`. It is disabled only when the operator explicitly
|
|
168
|
+
sets a false-y value, and a string value is treated as a CA-bundle path. TLS
|
|
169
|
+
therefore fails closed rather than open.
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
Requires a `hermes-agent` source checkout (for the `agent.*` packages and the
|
|
174
|
+
conformance kit at `tests/secret_sources/conformance.py`).
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
# host = hermes-agent checkout, plugin = this repo. Put the host on PYTHONPATH so
|
|
178
|
+
# `import tests` resolves to the host conformance package. This repo keeps its own
|
|
179
|
+
# tests in `checks/` (not `tests/`) precisely so it cannot shadow that package.
|
|
180
|
+
PYTHONPATH="/path/to/hermes-agent:$(pwd)" pytest
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`checks/test_conformance.py` runs the host `SecretSourceConformance` contract;
|
|
184
|
+
`checks/test_vault_source.py` covers behavior with `hvac` mocked (no network, no
|
|
185
|
+
Vault binary needed).
|
|
186
|
+
|
|
187
|
+
To run against a real dev server:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
vault server -dev # prints VAULT_ADDR + a root token
|
|
191
|
+
export VAULT_ADDR=http://127.0.0.1:8200
|
|
192
|
+
export VAULT_TOKEN=<root-token-from-output>
|
|
193
|
+
vault kv put secret/apps/my-agent API_KEY=abc DB_URL=postgres://x
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
hermes_vault_secret_source/__init__.py
|
|
5
|
+
hermes_vault_secret_source/_client.py
|
|
6
|
+
hermes_vault_secret_source.egg-info/PKG-INFO
|
|
7
|
+
hermes_vault_secret_source.egg-info/SOURCES.txt
|
|
8
|
+
hermes_vault_secret_source.egg-info/dependency_links.txt
|
|
9
|
+
hermes_vault_secret_source.egg-info/entry_points.txt
|
|
10
|
+
hermes_vault_secret_source.egg-info/requires.txt
|
|
11
|
+
hermes_vault_secret_source.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hermes_vault_secret_source
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hermes-vault-secret-source"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "HashiCorp Vault secret source for Hermes Agent (bulk KV v2)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "cryptoyasenka" }]
|
|
14
|
+
keywords = ["hermes", "hermes-agent", "vault", "hashicorp", "secrets", "plugin"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: System Administrators",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Security",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["hvac>=2.0"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
test = ["pytest>=7"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/cryptoyasenka/hermes-vault-secret-source"
|
|
34
|
+
Repository = "https://github.com/cryptoyasenka/hermes-vault-secret-source"
|
|
35
|
+
Issues = "https://github.com/cryptoyasenka/hermes-vault-secret-source/issues"
|
|
36
|
+
|
|
37
|
+
# Hermes' plugin loader discovers standalone plugins through this entry-point
|
|
38
|
+
# group. The value points at the package module that exposes `register(ctx)`
|
|
39
|
+
# (loader does `getattr(module, "register")`), so it must be the module, not
|
|
40
|
+
# `module:register`.
|
|
41
|
+
[project.entry-points."hermes_agent.plugins"]
|
|
42
|
+
hermes-hashicorp-vault = "hermes_vault_secret_source"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools]
|
|
45
|
+
packages = ["hermes_vault_secret_source"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
# importlib mode keeps this repo's `tests/` from shadowing the hermes-agent
|
|
49
|
+
# `tests` package (needed for tests.secret_sources.conformance).
|
|
50
|
+
addopts = "--import-mode=importlib"
|
|
51
|
+
|
|
52
|
+
[tool.mypy]
|
|
53
|
+
files = ["hermes_vault_secret_source"]
|
|
54
|
+
warn_redundant_casts = true
|
|
55
|
+
no_implicit_optional = true
|
|
56
|
+
|
|
57
|
+
# The host Hermes packages (agent.*) are resolved from a hermes-agent checkout on
|
|
58
|
+
# PYTHONPATH at runtime and in CI, and hvac ships no type stubs. Treat both as
|
|
59
|
+
# untyped external dependencies instead of type-checking their internals here.
|
|
60
|
+
[[tool.mypy.overrides]]
|
|
61
|
+
module = ["agent.*", "hvac", "hvac.*"]
|
|
62
|
+
ignore_missing_imports = true
|
|
63
|
+
follow_imports = "silent"
|