secrefs 0.2.0__py3-none-any.whl
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/__init__.py +110 -0
- secrefs/cli.py +140 -0
- secrefs/control_plane_client.py +206 -0
- secrefs/envfile.py +79 -0
- secrefs/parser.py +70 -0
- secrefs/providers/__init__.py +16 -0
- secrefs/providers/aws.py +156 -0
- secrefs/providers/base.py +89 -0
- secrefs/providers/bitwarden.py +257 -0
- secrefs/providers/local.py +79 -0
- secrefs/providers/vault.py +99 -0
- secrefs/resolver.py +138 -0
- secrefs/ttl_cache.py +109 -0
- secrefs-0.2.0.dist-info/METADATA +127 -0
- secrefs-0.2.0.dist-info/RECORD +18 -0
- secrefs-0.2.0.dist-info/WHEEL +4 -0
- secrefs-0.2.0.dist-info/entry_points.txt +3 -0
- secrefs-0.2.0.dist-info/licenses/LICENSE +21 -0
secrefs/resolver.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Dict, List, Mapping, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from .parser import ParsedSecretRef, is_secret_ref, parse_secret_ref
|
|
9
|
+
from .providers.base import SecretFetchRequest, SecretProvider
|
|
10
|
+
|
|
11
|
+
ProviderRegistry = Mapping[str, SecretProvider]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ResolutionFailure:
|
|
16
|
+
key: str
|
|
17
|
+
ref: str
|
|
18
|
+
message: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SecRefsResolutionError(Exception):
|
|
22
|
+
def __init__(self, errors: List[ResolutionFailure]) -> None:
|
|
23
|
+
self.errors = errors
|
|
24
|
+
lines = "\n".join(f" - {e.key}: {e.ref} -> {e.message}" for e in errors)
|
|
25
|
+
super().__init__(f"Failed to resolve {len(errors)} secret reference(s):\n{lines}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class CheckResult:
|
|
30
|
+
key: str
|
|
31
|
+
ref: str
|
|
32
|
+
provider: str
|
|
33
|
+
ok: bool
|
|
34
|
+
message: Optional[str] = None # present only when ok is False; never a secret value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _resolve_one(ref: ParsedSecretRef, providers: ProviderRegistry) -> str:
|
|
38
|
+
provider = providers.get(ref.provider)
|
|
39
|
+
if provider is None:
|
|
40
|
+
available = ", ".join(providers.keys()) or "none configured"
|
|
41
|
+
raise ValueError(f'unknown provider "{ref.provider}" (available: {available})')
|
|
42
|
+
return await provider.fetch_one(SecretFetchRequest(path=ref.path, field=ref.field))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def expand_key_value_map(
|
|
46
|
+
input_map: Mapping[str, Optional[str]],
|
|
47
|
+
providers: ProviderRegistry,
|
|
48
|
+
*,
|
|
49
|
+
strict: bool = True,
|
|
50
|
+
) -> Dict[str, str]:
|
|
51
|
+
"""
|
|
52
|
+
Expands every `sec://` value in a plain key/value map, resolving all
|
|
53
|
+
references concurrently. Non-reference values pass through untouched.
|
|
54
|
+
Raises SecRefsResolutionError aggregating every failed reference.
|
|
55
|
+
"""
|
|
56
|
+
output: Dict[str, str] = {}
|
|
57
|
+
pending: List[Tuple[str, ParsedSecretRef]] = []
|
|
58
|
+
|
|
59
|
+
for key, value in input_map.items():
|
|
60
|
+
if value is None:
|
|
61
|
+
continue
|
|
62
|
+
if not is_secret_ref(value):
|
|
63
|
+
output[key] = value
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
pending.append((key, parse_secret_ref(value)))
|
|
67
|
+
except ValueError:
|
|
68
|
+
if strict:
|
|
69
|
+
raise
|
|
70
|
+
output[key] = value
|
|
71
|
+
|
|
72
|
+
if not pending:
|
|
73
|
+
return output
|
|
74
|
+
|
|
75
|
+
results = await asyncio.gather(
|
|
76
|
+
*(_resolve_one(ref, providers) for _, ref in pending), return_exceptions=True
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
errors: List[ResolutionFailure] = []
|
|
80
|
+
for (key, ref), result in zip(pending, results):
|
|
81
|
+
if isinstance(result, BaseException):
|
|
82
|
+
errors.append(ResolutionFailure(key=key, ref=ref.raw, message=str(result)))
|
|
83
|
+
else:
|
|
84
|
+
output[key] = result
|
|
85
|
+
|
|
86
|
+
if errors:
|
|
87
|
+
raise SecRefsResolutionError(errors)
|
|
88
|
+
|
|
89
|
+
return output
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def expand_environ(providers: ProviderRegistry, *, strict: bool = True) -> List[str]:
|
|
93
|
+
"""Expands sec:// values found in os.environ, mutating it in place."""
|
|
94
|
+
resolved = await expand_key_value_map(dict(os.environ), providers, strict=strict)
|
|
95
|
+
changed_keys: List[str] = []
|
|
96
|
+
for key, value in resolved.items():
|
|
97
|
+
if os.environ.get(key) != value:
|
|
98
|
+
os.environ[key] = value
|
|
99
|
+
changed_keys.append(key)
|
|
100
|
+
return changed_keys
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def check_references(
|
|
104
|
+
input_map: Mapping[str, Optional[str]], providers: ProviderRegistry
|
|
105
|
+
) -> List[CheckResult]:
|
|
106
|
+
"""
|
|
107
|
+
Dry-run validation: resolves every sec:// reference in `input_map` but
|
|
108
|
+
reports only ok/failure - secret values are never returned or logged.
|
|
109
|
+
"""
|
|
110
|
+
results: List[CheckResult] = []
|
|
111
|
+
parsed_entries: List[Tuple[str, ParsedSecretRef]] = []
|
|
112
|
+
|
|
113
|
+
for key, value in input_map.items():
|
|
114
|
+
if not isinstance(value, str) or not is_secret_ref(value):
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
parsed_entries.append((key, parse_secret_ref(value)))
|
|
118
|
+
except ValueError as exc:
|
|
119
|
+
results.append(
|
|
120
|
+
CheckResult(key=key, ref=value, provider="unknown", ok=False, message=str(exc))
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
outcomes = await asyncio.gather(
|
|
124
|
+
*(_resolve_one(ref, providers) for _, ref in parsed_entries), return_exceptions=True
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
for (key, ref), outcome in zip(parsed_entries, outcomes):
|
|
128
|
+
results.append(
|
|
129
|
+
CheckResult(
|
|
130
|
+
key=key,
|
|
131
|
+
ref=ref.raw,
|
|
132
|
+
provider=ref.provider,
|
|
133
|
+
ok=not isinstance(outcome, BaseException),
|
|
134
|
+
message=str(outcome) if isinstance(outcome, BaseException) else None,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return results
|
secrefs/ttl_cache.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A cache that expires, used by every provider that fetches over the network.
|
|
3
|
+
|
|
4
|
+
The default TTL is **zero** - every read re-fetches. That's deliberate and
|
|
5
|
+
it's the whole point of the product: a `sec://` reference is a stable name
|
|
6
|
+
for a value that changes underneath it. A consumer holding the reference is
|
|
7
|
+
supposed to see a rotated secret without being redeployed, and a cache with
|
|
8
|
+
no expiry silently breaks exactly that. Before this existed, a long-running
|
|
9
|
+
process fetched a secret once and held the pre-rotation value until restart.
|
|
10
|
+
|
|
11
|
+
A non-zero TTL is a real tradeoff, not a mistake: every expansion is a
|
|
12
|
+
network round trip, so a busy caller may want to trade a bounded window of
|
|
13
|
+
staleness for latency and API-rate-limit headroom. `ttl_ms=30_000` means "a
|
|
14
|
+
rotation reaches me within 30 seconds" - usually fine, and it should be a
|
|
15
|
+
decision someone made rather than a default they inherited.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Awaitable, Callable, Dict, Generic, Optional, TypeVar
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _monotonic_ms() -> float:
|
|
29
|
+
"""Monotonic rather than wall clock: a TTL is a duration, and an NTP
|
|
30
|
+
step or a DST change shouldn't stretch or collapse one."""
|
|
31
|
+
return time.monotonic() * 1000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class _Entry(Generic[T]):
|
|
36
|
+
value: T
|
|
37
|
+
stored_at: float
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TtlCache(Generic[T]):
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
ttl_ms: float = 0.0,
|
|
44
|
+
now: Optional[Callable[[], float]] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""`ttl_ms` is how long an entry stays fresh; 0 (the default)
|
|
47
|
+
disables caching entirely - every `fetch` call goes to the source.
|
|
48
|
+
`now` is injected in tests so expiry doesn't require real waiting."""
|
|
49
|
+
self._ttl_ms = ttl_ms
|
|
50
|
+
self._now = now or _monotonic_ms
|
|
51
|
+
# Settled values, only ever populated when a TTL is configured.
|
|
52
|
+
self._entries: Dict[str, _Entry[T]] = {}
|
|
53
|
+
# Requests currently in flight, tracked separately from `_entries`
|
|
54
|
+
# because coalescing and caching are different things: sharing an
|
|
55
|
+
# unsettled request holds no value past the moment it resolves, so
|
|
56
|
+
# it stays correct even with caching fully disabled.
|
|
57
|
+
self._in_flight: Dict[str, "asyncio.Future[T]"] = {}
|
|
58
|
+
|
|
59
|
+
async def fetch(self, key: str, load: Callable[[], Awaitable[T]]) -> T:
|
|
60
|
+
"""
|
|
61
|
+
Returns the cached value for `key` if it's still fresh, otherwise
|
|
62
|
+
awaits `load()` and caches that. In-flight requests are shared, so N
|
|
63
|
+
concurrent expansions of the same reference make one request rather
|
|
64
|
+
than N even when the TTL is zero - that's request coalescing, not
|
|
65
|
+
caching, and it doesn't hold a value past its use.
|
|
66
|
+
|
|
67
|
+
A failed load is dropped rather than remembered, so a transient
|
|
68
|
+
failure doesn't become a sticky one.
|
|
69
|
+
"""
|
|
70
|
+
# Always join an in-flight request, whatever the TTL.
|
|
71
|
+
pending = self._in_flight.get(key)
|
|
72
|
+
if pending is None:
|
|
73
|
+
entry = self._entries.get(key)
|
|
74
|
+
if (
|
|
75
|
+
entry is not None
|
|
76
|
+
and self._ttl_ms > 0
|
|
77
|
+
and self._now() - entry.stored_at < self._ttl_ms
|
|
78
|
+
):
|
|
79
|
+
return entry.value
|
|
80
|
+
|
|
81
|
+
pending = asyncio.ensure_future(load())
|
|
82
|
+
self._in_flight[key] = pending
|
|
83
|
+
# Cleared by the request itself rather than in a `finally` below,
|
|
84
|
+
# so the entry disappears exactly when the request settles even
|
|
85
|
+
# if the caller that started it was cancelled first.
|
|
86
|
+
pending.add_done_callback(lambda _task: self._in_flight.pop(key, None))
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
# Shielded: one caller giving up (a cancelled `expand_string`,
|
|
90
|
+
# say) must not cancel the request every other caller joined.
|
|
91
|
+
value = await asyncio.shield(pending)
|
|
92
|
+
except Exception:
|
|
93
|
+
# Never remember a failure - a transient outage shouldn't become
|
|
94
|
+
# a sticky one for the length of the TTL.
|
|
95
|
+
self._entries.pop(key, None)
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
if self._ttl_ms > 0:
|
|
99
|
+
# The settled value, not the future it came from: a retained
|
|
100
|
+
# entry can outlive the event loop that produced it, and an
|
|
101
|
+
# awaitable can't be awaited from a different one.
|
|
102
|
+
self._entries[key] = _Entry(value=value, stored_at=self._now())
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
def clear(self) -> None:
|
|
106
|
+
"""Drops everything retained - used when a credential changes
|
|
107
|
+
underneath the cache and anything fetched with the old one is
|
|
108
|
+
suspect."""
|
|
109
|
+
self._entries.clear()
|
|
@@ -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,18 @@
|
|
|
1
|
+
secrefs/__init__.py,sha256=PRo1iW0AUziMChVjaLvrLmTxaXTVdGhn_XcmoxWJQag,3458
|
|
2
|
+
secrefs/cli.py,sha256=1mVzX900pUUnuteDO5xmqnCSIbGBXq5gTIIMepcQXdg,4286
|
|
3
|
+
secrefs/control_plane_client.py,sha256=LShXrxOIjx7Hf_yXh_jyKtpKFC42pvcqdeJRSsOFXaM,8209
|
|
4
|
+
secrefs/envfile.py,sha256=2JeZfxOkS1wkbZHumzTQVNprp3N43RgdIyBoRTlaPMI,2992
|
|
5
|
+
secrefs/parser.py,sha256=5U7hrjXDdn9_zKmgD3BHlsFdEZgyBHzvDsmq4fK__VU,2190
|
|
6
|
+
secrefs/providers/__init__.py,sha256=CckreWD7Uge_g34-X19PkCB4CwE_hNsMLDM2MTpbou8,446
|
|
7
|
+
secrefs/providers/aws.py,sha256=Rv2BA959YCIKxWCpBxxJAbVBdm-s3nLoOqXQwUYjPm8,7444
|
|
8
|
+
secrefs/providers/base.py,sha256=3bxDXQO-CXLnOAw3kaRA6mRdtEMQBQkEcX9c5mJSIIA,2784
|
|
9
|
+
secrefs/providers/bitwarden.py,sha256=6v4nHedtgtxhHKpbb8A4hvBGoVqoU5RjdBQk5zYjEJk,12531
|
|
10
|
+
secrefs/providers/local.py,sha256=CL-Iih0PmK4zQvoj6halK-jXi28BPmwtddCT8ijT9Yg,3022
|
|
11
|
+
secrefs/providers/vault.py,sha256=fzie33D_wQQhRCqbwFJ0fWF7BuEvxC6Z1SPCVMmWjB4,4051
|
|
12
|
+
secrefs/resolver.py,sha256=_hURsbWCcnxVBZz-PNrBYnpXyxrFwkxeR6_z9uMBPn4,4453
|
|
13
|
+
secrefs/ttl_cache.py,sha256=o1_JLkUr7Ayc4f-14JM7Fe2ZUgm8hcChUttauvoPcbc,4621
|
|
14
|
+
secrefs-0.2.0.dist-info/METADATA,sha256=n1NjRbij0YuDA-L1AGGgrNTFMlf6mS51JbYYwHwZFQU,4125
|
|
15
|
+
secrefs-0.2.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
16
|
+
secrefs-0.2.0.dist-info/entry_points.txt,sha256=lkHYgN1oW8WFP7O1cLaszl3QUI-UhZzHN8fPN-hAbXg,47
|
|
17
|
+
secrefs-0.2.0.dist-info/licenses/LICENSE,sha256=YxxxY7FGq0yNW4kXF-EjJCam-lq1XndIn8GS17fDQrk,1072
|
|
18
|
+
secrefs-0.2.0.dist-info/RECORD,,
|
|
@@ -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.
|