envshield 4.6.2__tar.gz → 4.7.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.
- {envshield-4.6.2 → envshield-4.7.0}/PKG-INFO +62 -5
- {envshield-4.6.2 → envshield-4.7.0}/README.md +61 -4
- envshield-4.7.0/envshield/__init__.py +1 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/cli.py +203 -55
- {envshield-4.6.2 → envshield-4.7.0}/envshield/config/manager.py +100 -24
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/dependency_snapshot.py +33 -6
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/discovery.py +245 -31
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/doctor.py +169 -18
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/explain.py +106 -26
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/importer.py +16 -1
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/scanner.py +27 -4
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/schema_manager.py +371 -5
- envshield-4.7.0/envshield/parsers/_deployment.py +126 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/parsers/_docker_compose.py +23 -14
- envshield-4.7.0/envshield/parsers/_kubernetes.py +222 -0
- envshield-4.7.0/envshield/utils/paths.py +49 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield.egg-info/PKG-INFO +62 -5
- envshield-4.7.0/envshield.egg-info/SOURCES.txt +46 -0
- {envshield-4.6.2 → envshield-4.7.0}/pyproject.toml +2 -1
- envshield-4.6.2/envshield/__init__.py +0 -1
- envshield-4.6.2/envshield/config/__init.py +0 -0
- envshield-4.6.2/envshield/parsers/_deployment.py +0 -63
- envshield-4.6.2/envshield/parsers/_kubernetes.py +0 -148
- envshield-4.6.2/envshield/tests/conftest.py +0 -33
- envshield-4.6.2/envshield/tests/core/test_contract_diff.py +0 -433
- envshield-4.6.2/envshield/tests/core/test_dependency_diff.py +0 -167
- envshield-4.6.2/envshield/tests/core/test_dependency_snapshot.py +0 -528
- envshield-4.6.2/envshield/tests/core/test_discovery.py +0 -574
- envshield-4.6.2/envshield/tests/core/test_doctor.py +0 -736
- envshield-4.6.2/envshield/tests/core/test_explain.py +0 -397
- envshield-4.6.2/envshield/tests/core/test_file_updater.py +0 -223
- envshield-4.6.2/envshield/tests/core/test_generator.py +0 -815
- envshield-4.6.2/envshield/tests/core/test_hook_injection.py +0 -282
- envshield-4.6.2/envshield/tests/core/test_hooks_manager.py +0 -37
- envshield-4.6.2/envshield/tests/core/test_importer.py +0 -808
- envshield-4.6.2/envshield/tests/core/test_post_merge_hook.py +0 -139
- envshield-4.6.2/envshield/tests/core/test_pre_commit_hook.py +0 -332
- envshield-4.6.2/envshield/tests/core/test_scanner_compliance.py +0 -1419
- envshield-4.6.2/envshield/tests/core/test_schema_manager.py +0 -1308
- envshield-4.6.2/envshield/tests/core/test_schema_snapshot.py +0 -424
- envshield-4.6.2/envshield/tests/core/test_schema_types.py +0 -347
- envshield-4.6.2/envshield/tests/core/test_service_discovery.py +0 -406
- envshield-4.6.2/envshield/tests/core/test_service_manager.py +0 -326
- envshield-4.6.2/envshield/tests/core/test_setup_manager.py +0 -1084
- envshield-4.6.2/envshield/tests/parsers/__init__.py +0 -0
- envshield-4.6.2/envshield/tests/parsers/test_docker_compose_parser.py +0 -436
- envshield-4.6.2/envshield/tests/parsers/test_dotenv_parser.py +0 -220
- envshield-4.6.2/envshield/tests/parsers/test_kubernetes_parser.py +0 -354
- envshield-4.6.2/envshield/tests/parsers/test_python_parser.py +0 -113
- envshield-4.6.2/envshield/tests/test_cli.py +0 -1569
- envshield-4.6.2/envshield/tests/test_config_manager.py +0 -951
- envshield-4.6.2/envshield/tests/test_diff_aware_scanning.py +0 -201
- envshield-4.6.2/envshield/tests/test_explain_cli.py +0 -306
- envshield-4.6.2/envshield/tests/test_git_utils.py +0 -463
- envshield-4.6.2/envshield/tests/test_hook_cli.py +0 -187
- envshield-4.6.2/envshield/tests/test_schema_diff_cli.py +0 -658
- envshield-4.6.2/envshield/tests/test_service_cli.py +0 -549
- envshield-4.6.2/envshield/tests/test_undeclared_cli.py +0 -482
- envshield-4.6.2/envshield/utils/__init__.py +0 -0
- envshield-4.6.2/envshield.egg-info/SOURCES.txt +0 -82
- {envshield-4.6.2 → envshield-4.7.0}/LICENSE.md +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/__main__.py +0 -0
- {envshield-4.6.2/envshield/core → envshield-4.7.0/envshield/config}/__init__.py +0 -0
- {envshield-4.6.2/envshield/parsers → envshield-4.7.0/envshield/core}/__init__.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/contract_diff.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/dependency_diff.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/exceptions.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/file_updater.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/generator.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/hooks_manager.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/inspector.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/schema_snapshot.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/schema_types.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/service_discovery.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/service_manager.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/core/setup_manager.py +0 -0
- {envshield-4.6.2/envshield/tests → envshield-4.7.0/envshield/parsers}/__init__.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/parsers/_base.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/parsers/_dotenv.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/parsers/_python.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/parsers/factory.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/state.py +0 -0
- {envshield-4.6.2/envshield/tests/core → envshield-4.7.0/envshield/utils}/__init__.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield/utils/git_utils.py +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield.egg-info/dependency_links.txt +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield.egg-info/entry_points.txt +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield.egg-info/requires.txt +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/envshield.egg-info/top_level.txt +0 -0
- {envshield-4.6.2 → envshield-4.7.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: envshield
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.7.0
|
|
4
4
|
Summary: EnvShield: environment variables as a version-controlled configuration contract.
|
|
5
5
|
Author-email: Rabbil Yasar Sajal <rabbilyasar@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -100,9 +100,11 @@ env.schema.toml
|
|
|
100
100
|
## Quick Start
|
|
101
101
|
|
|
102
102
|
```bash
|
|
103
|
-
|
|
103
|
+
pipx install envshield
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
Prefer an existing Python/pip workflow instead? `pip install envshield` works the same way, as an alternative.
|
|
107
|
+
|
|
106
108
|
In an existing project:
|
|
107
109
|
|
|
108
110
|
```bash
|
|
@@ -299,7 +301,7 @@ jobs:
|
|
|
299
301
|
steps:
|
|
300
302
|
- uses: actions/checkout@v4
|
|
301
303
|
with: { fetch-depth: 0 }
|
|
302
|
-
- run:
|
|
304
|
+
- run: pipx install envshield
|
|
303
305
|
- run: |
|
|
304
306
|
BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
|
305
307
|
envshield schema diff "$BASE" HEAD
|
|
@@ -353,12 +355,67 @@ envshield check --service api
|
|
|
353
355
|
|
|
354
356
|
Every command is service-aware. `--service` is optional with exactly one service configured, inferred automatically when you're standing inside a registered service's own directory, and only prompted for interactively when there's more than one and neither applies.
|
|
355
357
|
|
|
358
|
+
### Discovering reads in a shared library (`additional_source_roots`)
|
|
359
|
+
|
|
360
|
+
`undeclared` and `explain` only walk a service's own directory by default. If part of a service's real configuration is read from a shared internal library that lives outside that directory — a common-utilities package imported by several services, say — those reads are invisible to both commands, for every service that imports the library.
|
|
361
|
+
|
|
362
|
+
`additional_source_roots` widens a service's discovery scope to cover extra directories:
|
|
363
|
+
|
|
364
|
+
```yaml
|
|
365
|
+
services:
|
|
366
|
+
api:
|
|
367
|
+
schema: services/api/env.schema.toml
|
|
368
|
+
additional_source_roots:
|
|
369
|
+
- libs/shared
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
`undeclared --service api` and `explain --service api` now also look inside `libs/shared` — a variable read there counts toward `api`'s own discovery results exactly as if it lived in `services/api/` itself. The same directory can be listed by more than one service; each one independently sees the reads it contains. A path that doesn't exist yet contributes nothing (no error); a path resolving outside the project is refused, the same protection every other path in `envshield.yml` already has. `scan` is unaffected — it's already repository-wide.
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## Multiple sources per service (`completeness: union`)
|
|
377
|
+
|
|
378
|
+
By default, every registered source for a service — its `local_file`, each registered deployment manifest — is validated independently against the *whole* schema: `check`/`doctor` expect any one of them to be self-sufficient on its own. Some services genuinely don't work that way: part of the configuration lives in a local config file, the rest is supplied by a deployment manifest. Under the default model, a variable that only ever lives in one of them shows up as "missing" everywhere else, even though nothing is actually wrong.
|
|
379
|
+
|
|
380
|
+
Set `completeness: union` on a service to validate against the *combined* presence of all its registered sources instead — a variable satisfied by any one of them satisfies the service as a whole:
|
|
381
|
+
|
|
382
|
+
```yaml
|
|
383
|
+
services:
|
|
384
|
+
api:
|
|
385
|
+
schema: services/api/env.schema.toml
|
|
386
|
+
local_file: services/api/config/settings.py
|
|
387
|
+
completeness: union
|
|
388
|
+
manifests:
|
|
389
|
+
- file: docker-compose.yml
|
|
390
|
+
containers:
|
|
391
|
+
api: api
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
```toml
|
|
395
|
+
[DB_HOST]
|
|
396
|
+
description = "set in config/settings.py"
|
|
397
|
+
|
|
398
|
+
[FEATURE_MODE]
|
|
399
|
+
description = "set via docker-compose.yml"
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
With `completeness: union`, `check`/`doctor` stop expecting `config/settings.py` to declare `FEATURE_MODE`, or `docker-compose.yml` to set `DB_HOST` — the schema is satisfied as long as every variable is present *somewhere* among the registered sources. `envshield schema sync` respects this too: it only ever appends a variable to a Python `local_file` that's genuinely missing from every registered source, never one that another source already covers.
|
|
403
|
+
|
|
404
|
+
A few things worth knowing:
|
|
405
|
+
|
|
406
|
+
- **Opt-in, per service.** Unset (the default) means every source must be independently self-sufficient, exactly as before this existed — nothing changes unless you set the key.
|
|
407
|
+
- **Union means presence, not values.** Whether an ordinary variable is satisfied depends only on whether it's present (and valid) somewhere — never on merging, comparing, or picking between its values across sources. A blank or invalid value is still reported, wherever it occurs. **The one deliberate exception** is a `requiredIf` trigger's own value (see next) — deciding whether a condition currently holds has always meant reading one specific value, even before `completeness: union` existed; union only extends *where* that one value may come from, it doesn't turn this into general value merging.
|
|
408
|
+
- **A `requiredIf` condition (see [Conditional requirements](#conditional-requirements-requiredif) above) is resolved against the union too** — if the variable a condition depends on lives in a different source than the one being checked, `completeness: union` still finds it. Sources that agree on the trigger's value resolve normally; if two sources genuinely disagree, EnvShield reports it as an explicit conflict rather than guessing which one wins — registration order is never used as a tiebreaker.
|
|
409
|
+
- **A source that fails to load is always a failure**, even if the sources that did load happen to satisfy the schema between them. `doctor` reports this as two separate signals for a union-mode service — source health (does this file exist and parse) and registered-source completeness (does the union satisfy the schema) — so one can never quietly hide behind the other. `check --json` reflects the same split: each source's own result is still reported individually, plus one `combined` entry per union-mode service carrying the aggregate verdict.
|
|
410
|
+
|
|
356
411
|
---
|
|
357
412
|
|
|
358
413
|
## Supported languages / discovery
|
|
359
414
|
|
|
360
415
|
Source-code discovery (what powers `undeclared` and `explain`'s "used in source" section) is AST-based for **Python** (`os.environ.get`, `os.getenv`, `os.environ[...]`) and pattern-based for **JavaScript/TypeScript** (`process.env.X`, `process.env["X"]`, `import.meta.env.X`, including single-level destructuring). No other language is discovered yet — the schema and validation commands work with any stack, but `undeclared`/`explain` can only see what's read from these two.
|
|
361
416
|
|
|
417
|
+
`explain` additionally recognizes Flask's `current_app.config[...]`/`.get(...)` in Python — including the common `from flask import current_app as <alias>` import form — and reports it at **medium confidence**, shown inline as `(medium confidence)`: it's one level of indirection through a config object whose contents could come from anywhere, not a direct environment read. This recognition is deliberately `explain`-only — it never affects `undeclared`'s or `scan`'s pass/fail signal, so a Flask-config read can never quietly satisfy (or break) a completeness check the way a direct `os.environ`/`os.getenv` read does.
|
|
418
|
+
|
|
362
419
|
---
|
|
363
420
|
|
|
364
421
|
## Secret safety
|
|
@@ -374,8 +431,8 @@ A supporting check alongside the contract, not the product itself. `secret = tru
|
|
|
374
431
|
|
|
375
432
|
EnvShield is built around the patterns real projects actually use. A few narrower cases aren't handled yet — none of them let a secret leak or let a genuinely missing required variable pass as clean:
|
|
376
433
|
|
|
377
|
-
- **Docker Compose:** `${VAR}
|
|
378
|
-
- **Kubernetes:** `secretKeyRef`/`configMapKeyRef` are matched by the container's environment-variable name (`env[].name`), not the referenced Secret/ConfigMap
|
|
434
|
+
- **Docker Compose:** `${VAR}`, `${VAR:-default}`, and the no-colon `${VAR-default}` form are all supported. The `${VAR:?}`/`${VAR:+}` forms and embedded/multiple interpolation references in one value aren't evaluated yet.
|
|
435
|
+
- **Kubernetes:** `env[].valueFrom.secretKeyRef`/`configMapKeyRef` are matched by the container's environment-variable name (`env[].name`) — name your schema variable after the env var, not the key — and, when the referenced Secret/ConfigMap is defined in the same manifest, the referenced key is validated to actually exist there. `envFrom.prefix` is applied. `initContainers` are selectable via `--container` but never auto-selected by default.
|
|
379
436
|
- **Source discovery:** Python is AST-based; JavaScript/TypeScript is pattern-based. No other language is discovered yet.
|
|
380
437
|
- **.env:** multiline quoted values aren't supported.
|
|
381
438
|
|
|
@@ -41,9 +41,11 @@ env.schema.toml
|
|
|
41
41
|
## Quick Start
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
|
|
44
|
+
pipx install envshield
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
Prefer an existing Python/pip workflow instead? `pip install envshield` works the same way, as an alternative.
|
|
48
|
+
|
|
47
49
|
In an existing project:
|
|
48
50
|
|
|
49
51
|
```bash
|
|
@@ -240,7 +242,7 @@ jobs:
|
|
|
240
242
|
steps:
|
|
241
243
|
- uses: actions/checkout@v4
|
|
242
244
|
with: { fetch-depth: 0 }
|
|
243
|
-
- run:
|
|
245
|
+
- run: pipx install envshield
|
|
244
246
|
- run: |
|
|
245
247
|
BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
|
246
248
|
envshield schema diff "$BASE" HEAD
|
|
@@ -294,12 +296,67 @@ envshield check --service api
|
|
|
294
296
|
|
|
295
297
|
Every command is service-aware. `--service` is optional with exactly one service configured, inferred automatically when you're standing inside a registered service's own directory, and only prompted for interactively when there's more than one and neither applies.
|
|
296
298
|
|
|
299
|
+
### Discovering reads in a shared library (`additional_source_roots`)
|
|
300
|
+
|
|
301
|
+
`undeclared` and `explain` only walk a service's own directory by default. If part of a service's real configuration is read from a shared internal library that lives outside that directory — a common-utilities package imported by several services, say — those reads are invisible to both commands, for every service that imports the library.
|
|
302
|
+
|
|
303
|
+
`additional_source_roots` widens a service's discovery scope to cover extra directories:
|
|
304
|
+
|
|
305
|
+
```yaml
|
|
306
|
+
services:
|
|
307
|
+
api:
|
|
308
|
+
schema: services/api/env.schema.toml
|
|
309
|
+
additional_source_roots:
|
|
310
|
+
- libs/shared
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`undeclared --service api` and `explain --service api` now also look inside `libs/shared` — a variable read there counts toward `api`'s own discovery results exactly as if it lived in `services/api/` itself. The same directory can be listed by more than one service; each one independently sees the reads it contains. A path that doesn't exist yet contributes nothing (no error); a path resolving outside the project is refused, the same protection every other path in `envshield.yml` already has. `scan` is unaffected — it's already repository-wide.
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## Multiple sources per service (`completeness: union`)
|
|
318
|
+
|
|
319
|
+
By default, every registered source for a service — its `local_file`, each registered deployment manifest — is validated independently against the *whole* schema: `check`/`doctor` expect any one of them to be self-sufficient on its own. Some services genuinely don't work that way: part of the configuration lives in a local config file, the rest is supplied by a deployment manifest. Under the default model, a variable that only ever lives in one of them shows up as "missing" everywhere else, even though nothing is actually wrong.
|
|
320
|
+
|
|
321
|
+
Set `completeness: union` on a service to validate against the *combined* presence of all its registered sources instead — a variable satisfied by any one of them satisfies the service as a whole:
|
|
322
|
+
|
|
323
|
+
```yaml
|
|
324
|
+
services:
|
|
325
|
+
api:
|
|
326
|
+
schema: services/api/env.schema.toml
|
|
327
|
+
local_file: services/api/config/settings.py
|
|
328
|
+
completeness: union
|
|
329
|
+
manifests:
|
|
330
|
+
- file: docker-compose.yml
|
|
331
|
+
containers:
|
|
332
|
+
api: api
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
```toml
|
|
336
|
+
[DB_HOST]
|
|
337
|
+
description = "set in config/settings.py"
|
|
338
|
+
|
|
339
|
+
[FEATURE_MODE]
|
|
340
|
+
description = "set via docker-compose.yml"
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
With `completeness: union`, `check`/`doctor` stop expecting `config/settings.py` to declare `FEATURE_MODE`, or `docker-compose.yml` to set `DB_HOST` — the schema is satisfied as long as every variable is present *somewhere* among the registered sources. `envshield schema sync` respects this too: it only ever appends a variable to a Python `local_file` that's genuinely missing from every registered source, never one that another source already covers.
|
|
344
|
+
|
|
345
|
+
A few things worth knowing:
|
|
346
|
+
|
|
347
|
+
- **Opt-in, per service.** Unset (the default) means every source must be independently self-sufficient, exactly as before this existed — nothing changes unless you set the key.
|
|
348
|
+
- **Union means presence, not values.** Whether an ordinary variable is satisfied depends only on whether it's present (and valid) somewhere — never on merging, comparing, or picking between its values across sources. A blank or invalid value is still reported, wherever it occurs. **The one deliberate exception** is a `requiredIf` trigger's own value (see next) — deciding whether a condition currently holds has always meant reading one specific value, even before `completeness: union` existed; union only extends *where* that one value may come from, it doesn't turn this into general value merging.
|
|
349
|
+
- **A `requiredIf` condition (see [Conditional requirements](#conditional-requirements-requiredif) above) is resolved against the union too** — if the variable a condition depends on lives in a different source than the one being checked, `completeness: union` still finds it. Sources that agree on the trigger's value resolve normally; if two sources genuinely disagree, EnvShield reports it as an explicit conflict rather than guessing which one wins — registration order is never used as a tiebreaker.
|
|
350
|
+
- **A source that fails to load is always a failure**, even if the sources that did load happen to satisfy the schema between them. `doctor` reports this as two separate signals for a union-mode service — source health (does this file exist and parse) and registered-source completeness (does the union satisfy the schema) — so one can never quietly hide behind the other. `check --json` reflects the same split: each source's own result is still reported individually, plus one `combined` entry per union-mode service carrying the aggregate verdict.
|
|
351
|
+
|
|
297
352
|
---
|
|
298
353
|
|
|
299
354
|
## Supported languages / discovery
|
|
300
355
|
|
|
301
356
|
Source-code discovery (what powers `undeclared` and `explain`'s "used in source" section) is AST-based for **Python** (`os.environ.get`, `os.getenv`, `os.environ[...]`) and pattern-based for **JavaScript/TypeScript** (`process.env.X`, `process.env["X"]`, `import.meta.env.X`, including single-level destructuring). No other language is discovered yet — the schema and validation commands work with any stack, but `undeclared`/`explain` can only see what's read from these two.
|
|
302
357
|
|
|
358
|
+
`explain` additionally recognizes Flask's `current_app.config[...]`/`.get(...)` in Python — including the common `from flask import current_app as <alias>` import form — and reports it at **medium confidence**, shown inline as `(medium confidence)`: it's one level of indirection through a config object whose contents could come from anywhere, not a direct environment read. This recognition is deliberately `explain`-only — it never affects `undeclared`'s or `scan`'s pass/fail signal, so a Flask-config read can never quietly satisfy (or break) a completeness check the way a direct `os.environ`/`os.getenv` read does.
|
|
359
|
+
|
|
303
360
|
---
|
|
304
361
|
|
|
305
362
|
## Secret safety
|
|
@@ -315,8 +372,8 @@ A supporting check alongside the contract, not the product itself. `secret = tru
|
|
|
315
372
|
|
|
316
373
|
EnvShield is built around the patterns real projects actually use. A few narrower cases aren't handled yet — none of them let a secret leak or let a genuinely missing required variable pass as clean:
|
|
317
374
|
|
|
318
|
-
- **Docker Compose:** `${VAR}
|
|
319
|
-
- **Kubernetes:** `secretKeyRef`/`configMapKeyRef` are matched by the container's environment-variable name (`env[].name`), not the referenced Secret/ConfigMap
|
|
375
|
+
- **Docker Compose:** `${VAR}`, `${VAR:-default}`, and the no-colon `${VAR-default}` form are all supported. The `${VAR:?}`/`${VAR:+}` forms and embedded/multiple interpolation references in one value aren't evaluated yet.
|
|
376
|
+
- **Kubernetes:** `env[].valueFrom.secretKeyRef`/`configMapKeyRef` are matched by the container's environment-variable name (`env[].name`) — name your schema variable after the env var, not the key — and, when the referenced Secret/ConfigMap is defined in the same manifest, the referenced key is validated to actually exist there. `envFrom.prefix` is applied. `initContainers` are selectable via `--container` but never auto-selected by default.
|
|
320
377
|
- **Source discovery:** Python is AST-based; JavaScript/TypeScript is pattern-based. No other language is discovered yet.
|
|
321
378
|
- **.env:** multiline quoted values aren't supported.
|
|
322
379
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "4.7.0"
|
|
@@ -30,7 +30,11 @@ from .core import (
|
|
|
30
30
|
service_manager,
|
|
31
31
|
setup_manager,
|
|
32
32
|
)
|
|
33
|
-
from .core.exceptions import
|
|
33
|
+
from .core.exceptions import (
|
|
34
|
+
EnvShieldException,
|
|
35
|
+
SchemaNotFoundError,
|
|
36
|
+
VariableNotFoundError,
|
|
37
|
+
)
|
|
34
38
|
from .utils import git_utils
|
|
35
39
|
|
|
36
40
|
|
|
@@ -134,6 +138,52 @@ def _print_service_header(targets: List[str], target: str) -> None:
|
|
|
134
138
|
console.print(f"\n[bold underline]── {target} ──[/bold underline]")
|
|
135
139
|
|
|
136
140
|
|
|
141
|
+
def _print_union_completeness_summary(
|
|
142
|
+
service_name: str,
|
|
143
|
+
union_result: "schema_manager.UnionCompletenessResult",
|
|
144
|
+
source_errors: List[str],
|
|
145
|
+
) -> None:
|
|
146
|
+
"""
|
|
147
|
+
`check`'s Rich-output counterpart to each source's own individual diff
|
|
148
|
+
table above it -- explains *why* a target with one or more
|
|
149
|
+
individually-incomplete-looking source tables can still exit 0 (or
|
|
150
|
+
still fail, for a reason none of those tables show on their own, like
|
|
151
|
+
a cross-source requiredIf ambiguity or a source that failed to load).
|
|
152
|
+
"""
|
|
153
|
+
console.print(
|
|
154
|
+
f"\n[bold]Registered-source completeness ({service_name}, completeness: union)[/bold]"
|
|
155
|
+
)
|
|
156
|
+
for error in source_errors:
|
|
157
|
+
console.print(f" [bold red]✗[/bold red] Source error: {error}")
|
|
158
|
+
if union_result.is_clean and not source_errors:
|
|
159
|
+
console.print(
|
|
160
|
+
" [bold green]✓ Satisfied by the union of registered sources.[/bold green]"
|
|
161
|
+
)
|
|
162
|
+
return
|
|
163
|
+
if union_result.missing:
|
|
164
|
+
console.print(
|
|
165
|
+
" [red]Missing from every registered source:[/red] "
|
|
166
|
+
f"{', '.join(sorted(union_result.missing))}"
|
|
167
|
+
)
|
|
168
|
+
if union_result.blank:
|
|
169
|
+
console.print(
|
|
170
|
+
" [red]Blank everywhere it's declared:[/red] "
|
|
171
|
+
f"{', '.join(sorted(union_result.blank))}"
|
|
172
|
+
)
|
|
173
|
+
if union_result.invalid:
|
|
174
|
+
details = "; ".join(
|
|
175
|
+
f"{k} ({v})" for k, v in sorted(union_result.invalid.items())
|
|
176
|
+
)
|
|
177
|
+
console.print(f" [red]Invalid:[/red] {details}")
|
|
178
|
+
if union_result.unresolved:
|
|
179
|
+
console.print(
|
|
180
|
+
" [yellow]Cannot confirm (a registered source has an unresolved "
|
|
181
|
+
f"external reference):[/yellow] {', '.join(sorted(union_result.unresolved))}"
|
|
182
|
+
)
|
|
183
|
+
for key, reason in sorted(union_result.ambiguous_requiredif.items()):
|
|
184
|
+
console.print(f" [red]Ambiguous ({key}):[/red] {reason}")
|
|
185
|
+
|
|
186
|
+
|
|
137
187
|
# --- Commands ---
|
|
138
188
|
@app.command()
|
|
139
189
|
def init(
|
|
@@ -438,9 +488,18 @@ def check(
|
|
|
438
488
|
|
|
439
489
|
had_error = False
|
|
440
490
|
results = []
|
|
491
|
+
combined: Dict[str, Any] = {}
|
|
441
492
|
for target in targets:
|
|
442
493
|
if not json_output:
|
|
443
494
|
_print_service_header(targets, target)
|
|
495
|
+
# completeness: union (BL-030) is opt-in per service and only
|
|
496
|
+
# applies to the service's own default source set -- an explicit
|
|
497
|
+
# `file` argument means the user asked for exactly that one file,
|
|
498
|
+
# which is unaffected by (and skips) union evaluation entirely,
|
|
499
|
+
# same as it already skips registered manifests below.
|
|
500
|
+
is_union = (
|
|
501
|
+
not file and config_manager.get_service_completeness_mode(target) == "union"
|
|
502
|
+
)
|
|
444
503
|
try:
|
|
445
504
|
resolved_file = (
|
|
446
505
|
file or config_manager.get_env_paths(service_name=target)["local_file"]
|
|
@@ -450,12 +509,13 @@ def check(
|
|
|
450
509
|
resolved_file, service_name=target, container=container
|
|
451
510
|
)
|
|
452
511
|
results.append(result)
|
|
453
|
-
if not result["clean"]:
|
|
512
|
+
if not is_union and not result["clean"]:
|
|
454
513
|
had_error = True
|
|
455
514
|
elif not schema_manager.check_schema(
|
|
456
515
|
resolved_file, service_name=target, container=container
|
|
457
516
|
):
|
|
458
|
-
|
|
517
|
+
if not is_union:
|
|
518
|
+
had_error = True
|
|
459
519
|
|
|
460
520
|
# An explicit file argument means the user asked for exactly
|
|
461
521
|
# that file, and nothing else -- only pile on registered
|
|
@@ -473,14 +533,42 @@ def check(
|
|
|
473
533
|
container=manifest_container,
|
|
474
534
|
)
|
|
475
535
|
results.append(result)
|
|
476
|
-
if not result["clean"]:
|
|
536
|
+
if not is_union and not result["clean"]:
|
|
477
537
|
had_error = True
|
|
478
538
|
elif not schema_manager.check_schema(
|
|
479
539
|
manifest["path"],
|
|
480
540
|
service_name=target,
|
|
481
541
|
container=manifest_container,
|
|
482
542
|
):
|
|
483
|
-
|
|
543
|
+
if not is_union:
|
|
544
|
+
had_error = True
|
|
545
|
+
|
|
546
|
+
# completeness: union's own aggregate verdict, computed
|
|
547
|
+
# separately from (and never replacing) the per-source diffs
|
|
548
|
+
# above -- those keep reporting each source's own individual
|
|
549
|
+
# gaps for visibility; this decides whether the TARGET as a
|
|
550
|
+
# whole passes. A source that failed to load here is always a
|
|
551
|
+
# failure, independent of whether the sources that DID load
|
|
552
|
+
# happen to satisfy the schema between them (BL-030's explicit
|
|
553
|
+
# source-health requirement).
|
|
554
|
+
if is_union:
|
|
555
|
+
schema = config_manager.load_schema(service_name=target)
|
|
556
|
+
sources, source_errors = schema_manager.load_union_sources(
|
|
557
|
+
target, container=container
|
|
558
|
+
)
|
|
559
|
+
union_result = schema_manager.evaluate_union_completeness(
|
|
560
|
+
schema, sources
|
|
561
|
+
)
|
|
562
|
+
if source_errors or not union_result.is_clean:
|
|
563
|
+
had_error = True
|
|
564
|
+
if json_output:
|
|
565
|
+
combined[target] = schema_manager.union_completeness_result_to_dict(
|
|
566
|
+
union_result, source_errors=source_errors
|
|
567
|
+
)
|
|
568
|
+
else:
|
|
569
|
+
_print_union_completeness_summary(
|
|
570
|
+
target, union_result, source_errors
|
|
571
|
+
)
|
|
484
572
|
except EnvShieldException as e:
|
|
485
573
|
if json_output:
|
|
486
574
|
results.append({"service": target, "clean": False, "error": str(e)})
|
|
@@ -489,7 +577,10 @@ def check(
|
|
|
489
577
|
had_error = True
|
|
490
578
|
|
|
491
579
|
if json_output:
|
|
492
|
-
|
|
580
|
+
payload: Dict[str, Any] = {"success": not had_error, "results": results}
|
|
581
|
+
if combined:
|
|
582
|
+
payload["combined"] = combined
|
|
583
|
+
print(json.dumps(payload, indent=2))
|
|
493
584
|
|
|
494
585
|
if had_error:
|
|
495
586
|
raise typer.Exit(code=1)
|
|
@@ -572,6 +663,100 @@ _EXPLAIN_REQUIREDNESS_LABEL = {
|
|
|
572
663
|
}
|
|
573
664
|
|
|
574
665
|
|
|
666
|
+
def _render_source_usages(
|
|
667
|
+
source_usages: List["explain.discovery.DiscoveredVariableUsage"],
|
|
668
|
+
) -> None:
|
|
669
|
+
"""Shared between the declared-variable report and the undeclared one -- the exact same section, so the two commands' output stays visually consistent."""
|
|
670
|
+
console.print("\n[bold]Used in source[/bold]")
|
|
671
|
+
if source_usages:
|
|
672
|
+
for usage in source_usages:
|
|
673
|
+
line = f" {usage.file_path}:{usage.line}"
|
|
674
|
+
if usage.confidence != "high":
|
|
675
|
+
# BL-113: a Flask current_app.config[...] read is one level
|
|
676
|
+
# of indirection through an object whose contents came from
|
|
677
|
+
# somewhere unspecified -- never as certain as a direct
|
|
678
|
+
# os.environ/os.getenv read, and this must stay visible at
|
|
679
|
+
# the point of reading, not just in --json's confidence field.
|
|
680
|
+
line += f" [dim]({usage.confidence} confidence)[/dim]"
|
|
681
|
+
console.print(line)
|
|
682
|
+
else:
|
|
683
|
+
console.print(" None discovered")
|
|
684
|
+
console.print(
|
|
685
|
+
" [dim]EnvShield only recognizes os.environ/os.getenv/process.env-style "
|
|
686
|
+
"reads in Python and JS/TS -- this doesn't prove the variable is unused.[/dim]"
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _render_manifest_references(
|
|
691
|
+
manifest_references: List["explain.ManifestReference"], variable: str
|
|
692
|
+
) -> None:
|
|
693
|
+
"""Shared between the declared-variable report and the undeclared one."""
|
|
694
|
+
console.print("\n[bold]Deployment[/bold]")
|
|
695
|
+
if not manifest_references:
|
|
696
|
+
console.print(" No deployment manifest registered for this service.")
|
|
697
|
+
return
|
|
698
|
+
|
|
699
|
+
declared = [r for r in manifest_references if r.status == "declared"]
|
|
700
|
+
not_declared = [r for r in manifest_references if r.status == "not_declared"]
|
|
701
|
+
unresolved = [r for r in manifest_references if r.status == "unresolved"]
|
|
702
|
+
errored = [r for r in manifest_references if r.status == "error"]
|
|
703
|
+
if declared:
|
|
704
|
+
for ref in declared:
|
|
705
|
+
console.print(f" {ref.path}")
|
|
706
|
+
if ref.container:
|
|
707
|
+
console.print(f" container: {ref.container}")
|
|
708
|
+
elif not_declared:
|
|
709
|
+
# Only claim "not found" when at least one manifest positively
|
|
710
|
+
# doesn't declare it -- printing this alongside "Cannot confirm"
|
|
711
|
+
# below (when every non-declared reference is actually unresolved)
|
|
712
|
+
# would read as EnvShield confidently asserting absence in the
|
|
713
|
+
# same breath it admits it can't tell.
|
|
714
|
+
checked = ", ".join(r.path for r in manifest_references)
|
|
715
|
+
console.print(
|
|
716
|
+
" Not found in any registered deployment manifest "
|
|
717
|
+
f"(checked: {checked}) -- this doesn't prove it isn't deployed elsewhere."
|
|
718
|
+
)
|
|
719
|
+
if unresolved:
|
|
720
|
+
console.print(
|
|
721
|
+
f" [yellow]Cannot confirm for {len(unresolved)} manifest(s): "
|
|
722
|
+
f"{', '.join(r.path for r in unresolved)} -- references an external "
|
|
723
|
+
f"ConfigMap/Secret not included in the manifest; '{variable}' "
|
|
724
|
+
"may be supplied from there.[/yellow]"
|
|
725
|
+
)
|
|
726
|
+
if errored:
|
|
727
|
+
console.print(
|
|
728
|
+
f" [dim]Could not check {len(errored)} manifest(s): "
|
|
729
|
+
f"{', '.join(r.path for r in errored)}[/dim]"
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _render_undeclared_explain_report(
|
|
734
|
+
report: "explain.UndeclaredVariableReport",
|
|
735
|
+
) -> None:
|
|
736
|
+
"""
|
|
737
|
+
'explain' on a variable that isn't in the schema yet -- degrades
|
|
738
|
+
gracefully instead of only erroring, reusing the exact same
|
|
739
|
+
source-usage/manifest-reference rendering a declared variable's report
|
|
740
|
+
uses, so a developer investigating something 'undeclared'/'scan' just
|
|
741
|
+
found gets the same evidence, framed as "not declared yet" rather than
|
|
742
|
+
a bare error.
|
|
743
|
+
"""
|
|
744
|
+
console.print(f"\n[bold cyan]{report.variable}[/bold cyan]")
|
|
745
|
+
console.print("─" * max(len(report.variable), 8))
|
|
746
|
+
console.print(
|
|
747
|
+
f"\n[yellow]Not declared in the schema for service '{report.service}'.[/yellow]"
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
_render_source_usages(report.source_usages)
|
|
751
|
+
_render_manifest_references(report.manifest_references, report.variable)
|
|
752
|
+
|
|
753
|
+
if report.source_usages:
|
|
754
|
+
console.print(
|
|
755
|
+
"\n[dim]Add it to the schema once you know what it should require -- "
|
|
756
|
+
"see the file/line above for where it's read.[/dim]"
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
|
|
575
760
|
def _render_explain_report(report: "explain.ExplainReport") -> None:
|
|
576
761
|
console.print(f"\n[bold cyan]{report.variable}[/bold cyan]")
|
|
577
762
|
console.print("─" * max(len(report.variable), 8))
|
|
@@ -607,16 +792,7 @@ def _render_explain_report(report: "explain.ExplainReport") -> None:
|
|
|
607
792
|
" [dim](could not determine whether this is inherited via extends)[/dim]"
|
|
608
793
|
)
|
|
609
794
|
|
|
610
|
-
|
|
611
|
-
if report.source_usages:
|
|
612
|
-
for usage in report.source_usages:
|
|
613
|
-
console.print(f" {usage.file_path}:{usage.line}")
|
|
614
|
-
else:
|
|
615
|
-
console.print(" None discovered")
|
|
616
|
-
console.print(
|
|
617
|
-
" [dim]EnvShield only recognizes os.environ/os.getenv/process.env-style "
|
|
618
|
-
"reads in Python and JS/TS -- this doesn't prove the variable is unused.[/dim]"
|
|
619
|
-
)
|
|
795
|
+
_render_source_usages(report.source_usages)
|
|
620
796
|
|
|
621
797
|
if report.required_by:
|
|
622
798
|
console.print("\n[bold]Required by[/bold]")
|
|
@@ -627,45 +803,7 @@ def _render_explain_report(report: "explain.ExplainReport") -> None:
|
|
|
627
803
|
f' when {report.variable} == "{condition.get("equals", "true")}"'
|
|
628
804
|
)
|
|
629
805
|
|
|
630
|
-
|
|
631
|
-
if not report.manifest_references:
|
|
632
|
-
console.print(" No deployment manifest registered for this service.")
|
|
633
|
-
else:
|
|
634
|
-
declared = [r for r in report.manifest_references if r.status == "declared"]
|
|
635
|
-
not_declared = [
|
|
636
|
-
r for r in report.manifest_references if r.status == "not_declared"
|
|
637
|
-
]
|
|
638
|
-
unresolved = [r for r in report.manifest_references if r.status == "unresolved"]
|
|
639
|
-
errored = [r for r in report.manifest_references if r.status == "error"]
|
|
640
|
-
if declared:
|
|
641
|
-
for ref in declared:
|
|
642
|
-
console.print(f" {ref.path}")
|
|
643
|
-
if ref.container:
|
|
644
|
-
console.print(f" container: {ref.container}")
|
|
645
|
-
elif not_declared:
|
|
646
|
-
# Only claim "not found" when at least one manifest positively
|
|
647
|
-
# doesn't declare it -- printing this alongside "Cannot
|
|
648
|
-
# confirm" below (when every non-declared reference is
|
|
649
|
-
# actually unresolved) would read as EnvShield confidently
|
|
650
|
-
# asserting absence in the same breath it admits it can't
|
|
651
|
-
# tell.
|
|
652
|
-
checked = ", ".join(r.path for r in report.manifest_references)
|
|
653
|
-
console.print(
|
|
654
|
-
" Not found in any registered deployment manifest "
|
|
655
|
-
f"(checked: {checked}) -- this doesn't prove it isn't deployed elsewhere."
|
|
656
|
-
)
|
|
657
|
-
if unresolved:
|
|
658
|
-
console.print(
|
|
659
|
-
f" [yellow]Cannot confirm for {len(unresolved)} manifest(s): "
|
|
660
|
-
f"{', '.join(r.path for r in unresolved)} -- references an external "
|
|
661
|
-
f"ConfigMap/Secret not included in the manifest; '{report.variable}' "
|
|
662
|
-
"may be supplied from there.[/yellow]"
|
|
663
|
-
)
|
|
664
|
-
if errored:
|
|
665
|
-
console.print(
|
|
666
|
-
f" [dim]Could not check {len(errored)} manifest(s): "
|
|
667
|
-
f"{', '.join(r.path for r in errored)}[/dim]"
|
|
668
|
-
)
|
|
806
|
+
_render_manifest_references(report.manifest_references, report.variable)
|
|
669
807
|
|
|
670
808
|
|
|
671
809
|
@app.command(name="explain")
|
|
@@ -703,6 +841,16 @@ def explain_command(
|
|
|
703
841
|
|
|
704
842
|
try:
|
|
705
843
|
report = explain.build_report(variable, resolved_service)
|
|
844
|
+
except VariableNotFoundError:
|
|
845
|
+
# Degrade gracefully instead of only erroring -- exactly the
|
|
846
|
+
# workflow moment a developer reaches for 'explain' most: a
|
|
847
|
+
# variable 'undeclared'/'scan' just found, not yet in the schema.
|
|
848
|
+
undeclared_report = explain.build_undeclared_report(variable, resolved_service)
|
|
849
|
+
if json_output:
|
|
850
|
+
print(json.dumps(undeclared_report.to_dict(), indent=2))
|
|
851
|
+
else:
|
|
852
|
+
_render_undeclared_explain_report(undeclared_report)
|
|
853
|
+
raise typer.Exit(code=1)
|
|
706
854
|
except EnvShieldException as e:
|
|
707
855
|
if json_output:
|
|
708
856
|
print(
|