envshield 4.7.3__tar.gz → 4.7.4__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.
Files changed (53) hide show
  1. {envshield-4.7.3 → envshield-4.7.4}/PKG-INFO +82 -6
  2. {envshield-4.7.3 → envshield-4.7.4}/README.md +81 -5
  3. envshield-4.7.4/envshield/__init__.py +1 -0
  4. {envshield-4.7.3 → envshield-4.7.4}/envshield/cli.py +6 -0
  5. {envshield-4.7.3 → envshield-4.7.4}/envshield/config/manager.py +16 -0
  6. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/hooks_manager.py +56 -10
  7. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/scanner.py +10 -1
  8. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/service_discovery.py +19 -2
  9. {envshield-4.7.3 → envshield-4.7.4}/envshield.egg-info/PKG-INFO +82 -6
  10. {envshield-4.7.3 → envshield-4.7.4}/pyproject.toml +1 -1
  11. envshield-4.7.3/envshield/__init__.py +0 -1
  12. {envshield-4.7.3 → envshield-4.7.4}/LICENSE.md +0 -0
  13. {envshield-4.7.3 → envshield-4.7.4}/envshield/__main__.py +0 -0
  14. {envshield-4.7.3 → envshield-4.7.4}/envshield/classifier/__init__.py +0 -0
  15. {envshield-4.7.3 → envshield-4.7.4}/envshield/classifier/context_classifier.py +0 -0
  16. {envshield-4.7.3 → envshield-4.7.4}/envshield/classifier/tokenizer.py +0 -0
  17. {envshield-4.7.3 → envshield-4.7.4}/envshield/config/__init__.py +0 -0
  18. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/__init__.py +0 -0
  19. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/contract_diff.py +0 -0
  20. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/dependency_diff.py +0 -0
  21. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/dependency_snapshot.py +0 -0
  22. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/discovery.py +0 -0
  23. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/doctor.py +0 -0
  24. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/enforcement.py +0 -0
  25. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/exceptions.py +0 -0
  26. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/explain.py +0 -0
  27. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/file_updater.py +0 -0
  28. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/generator.py +0 -0
  29. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/importer.py +0 -0
  30. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/inspector.py +0 -0
  31. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/schema_manager.py +0 -0
  32. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/schema_snapshot.py +0 -0
  33. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/schema_types.py +0 -0
  34. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/service_manager.py +0 -0
  35. {envshield-4.7.3 → envshield-4.7.4}/envshield/core/setup_manager.py +0 -0
  36. {envshield-4.7.3 → envshield-4.7.4}/envshield/parsers/__init__.py +0 -0
  37. {envshield-4.7.3 → envshield-4.7.4}/envshield/parsers/_base.py +0 -0
  38. {envshield-4.7.3 → envshield-4.7.4}/envshield/parsers/_deployment.py +0 -0
  39. {envshield-4.7.3 → envshield-4.7.4}/envshield/parsers/_docker_compose.py +0 -0
  40. {envshield-4.7.3 → envshield-4.7.4}/envshield/parsers/_dotenv.py +0 -0
  41. {envshield-4.7.3 → envshield-4.7.4}/envshield/parsers/_kubernetes.py +0 -0
  42. {envshield-4.7.3 → envshield-4.7.4}/envshield/parsers/_python.py +0 -0
  43. {envshield-4.7.3 → envshield-4.7.4}/envshield/parsers/factory.py +0 -0
  44. {envshield-4.7.3 → envshield-4.7.4}/envshield/state.py +0 -0
  45. {envshield-4.7.3 → envshield-4.7.4}/envshield/utils/__init__.py +0 -0
  46. {envshield-4.7.3 → envshield-4.7.4}/envshield/utils/git_utils.py +0 -0
  47. {envshield-4.7.3 → envshield-4.7.4}/envshield/utils/paths.py +0 -0
  48. {envshield-4.7.3 → envshield-4.7.4}/envshield.egg-info/SOURCES.txt +0 -0
  49. {envshield-4.7.3 → envshield-4.7.4}/envshield.egg-info/dependency_links.txt +0 -0
  50. {envshield-4.7.3 → envshield-4.7.4}/envshield.egg-info/entry_points.txt +0 -0
  51. {envshield-4.7.3 → envshield-4.7.4}/envshield.egg-info/requires.txt +0 -0
  52. {envshield-4.7.3 → envshield-4.7.4}/envshield.egg-info/top_level.txt +0 -0
  53. {envshield-4.7.3 → envshield-4.7.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envshield
3
- Version: 4.7.3
3
+ Version: 4.7.4
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
@@ -270,7 +270,7 @@ Every command above documents its own options with `--help`; the full reference
270
270
 
271
271
  ## Git / CI workflow
272
272
 
273
- This is where the schema stops being documentation and starts being enforcement. Three concrete things EnvShield catches in a PR:
273
+ This is where the schema stops being documentation and starts being enforcement. Three concrete things EnvShield catches before a change reaches production:
274
274
 
275
275
  **A new environment variable used in code but missing from the schema:**
276
276
 
@@ -284,12 +284,14 @@ envshield undeclared origin/main HEAD --service api
284
284
  envshield schema diff origin/main HEAD --service api
285
285
  ```
286
286
 
287
- **A secret accidentally introduced into a commit** — the pre-commit hook runs `scan --staged` automatically:
287
+ **A secret accidentally introduced into a commit:**
288
288
 
289
289
  ```bash
290
290
  envshield hook install
291
291
  ```
292
292
 
293
+ The pre-commit hook runs `scan --staged --enforce`, applying classification-based enforcement with interactive override for high-confidence findings on local commits, and strict blocking in CI. See [Secret safety](#secret-safety) for enforcement behavior details.
294
+
293
295
  In CI, diff against the merge-base, not the base branch's current tip (which can move while the PR is open):
294
296
 
295
297
  ```yaml
@@ -466,10 +468,84 @@ Source-code discovery (what powers `undeclared` and `explain`'s "used in source"
466
468
 
467
469
  ## Secret safety
468
470
 
469
- A supporting check alongside the contract, not the product itself. `secret = true` on a field tells EnvShield (and `setup`, and code generation) that it's sensitive — the schema records that fact, never the actual value. On top of that:
471
+ A supporting check alongside the contract, not the product itself. `secret = true` on a field tells EnvShield (and `setup`, and code generation) that it's sensitive — the schema records that fact, never the actual value.
472
+
473
+ ### Secret scanning
474
+
475
+ **`envshield scan`** looks for hardcoded secrets by pattern (Stripe, AWS, GitHub tokens, and more), and for env-var reads the schema doesn't declare, in one pass. Values are always redacted in output — a Stripe *publishable* key (`pk_...`) is never flagged, since detection matches the secret-key pattern (`sk_...`) by shape, not the variable's name.
476
+
477
+ ### Context-aware classification
478
+
479
+ Python candidates are analyzed for syntactic context to distinguish code patterns from secret values:
480
+
481
+ ```python
482
+ # Classified as code (suppressed):
483
+ client(key=CONFIG_CONSTANT)
484
+
485
+
486
+ def handler(api_key: str): ...
487
+
488
+
489
+ # Classified as likely secret (detected):
490
+ client(key="sk_live_abc123...")
491
+ ```
492
+
493
+ This reduces false positives on ordinary code patterns without broadly suppressing secret-shaped values. Classification is lightweight and syntactic — it recognizes type annotations, function arguments, and identifier references, but doesn't perform semantic analysis beyond what the tokenizer can see.
494
+
495
+ ### Git hook enforcement
496
+
497
+ **`envshield hook install`** wires secret scanning into your Git workflow with classification-based enforcement:
498
+
499
+ ```bash
500
+ envshield hook install
501
+ ```
502
+
503
+ The pre-commit hook runs `scan --staged --enforce`, which scans only staged content and applies an enforcement policy based on classification confidence.
504
+
505
+ #### Enforcement behavior
506
+
507
+ | Finding type | Interactive (local commit) | Non-interactive (CI) |
508
+ |---|---|---|
509
+ | No findings | Allow | Allow |
510
+ | Clearly code-shaped | Suppressed | Suppressed |
511
+ | Ambiguous | Block | Block |
512
+ | High-confidence secret | Block + explicit override | Block |
513
+
514
+ **Clearly code-shaped** candidates (type annotations, function keyword arguments, etc.) are suppressed by the classifier and don't produce findings.
515
+
516
+ **Ambiguous** findings — insufficient context to confidently classify — block without override in both modes.
517
+
518
+ **High-confidence secrets** (string literals matching secret patterns) block by default but offer an interactive override for local commits:
519
+
520
+ ```
521
+ ⚠ EnvShield found potential secrets
522
+
523
+ HIGH CONFIDENCE
524
+ src/config.py:42 Generic API Key [REDACTED (30 chars)]
525
+
526
+ [1] Abort commit
527
+ [2] Commit anyway
528
+
529
+ Choice: 2
530
+
531
+ Type COMMIT ANYWAY to confirm: COMMIT ANYWAY
532
+
533
+ Proceeding with commit. The finding remains in the repository history.
534
+ ```
535
+
536
+ The override requires exact confirmation text — `yes` or `Y` won't work. EOF (Ctrl+D) or Ctrl+C aborts. Raw secret values are never displayed in enforcement output.
537
+
538
+ In CI or other non-interactive contexts, high-confidence findings block with no prompt — the commit is refused, and the scan exits non-zero.
539
+
540
+ #### Scanning modes
541
+
542
+ ```bash
543
+ envshield scan # Audit entire codebase
544
+ envshield scan --staged # Scan only staged Git content
545
+ envshield scan --staged --enforce # Staged scan + enforcement policy
546
+ ```
470
547
 
471
- - **`envshield scan`** looks for hardcoded secrets by pattern (Stripe, AWS, GitHub tokens, and more), and for env-var reads the schema doesn't declare, in one pass. Values are always redacted in output — a Stripe *publishable* key (`pk_...`) is never flagged, since detection matches the secret-key pattern (`sk_...`) by shape, not the variable's name. Python candidates are context-aware: code-shaped patterns like `key=CONSTANT` or `api_key: str` may be suppressed when they're clearly not secret values, while secret-shaped string literals remain detectable regardless of context.
472
- - **`envshield hook install`** wires `scan --staged` into a pre-commit hook, so a real secret is caught before it's committed, not after.
548
+ The pre-commit hook uses the third form. `scan` without flags is for repository-wide audits, not commit-time enforcement.
473
549
 
474
550
  ---
475
551
 
@@ -211,7 +211,7 @@ Every command above documents its own options with `--help`; the full reference
211
211
 
212
212
  ## Git / CI workflow
213
213
 
214
- This is where the schema stops being documentation and starts being enforcement. Three concrete things EnvShield catches in a PR:
214
+ This is where the schema stops being documentation and starts being enforcement. Three concrete things EnvShield catches before a change reaches production:
215
215
 
216
216
  **A new environment variable used in code but missing from the schema:**
217
217
 
@@ -225,12 +225,14 @@ envshield undeclared origin/main HEAD --service api
225
225
  envshield schema diff origin/main HEAD --service api
226
226
  ```
227
227
 
228
- **A secret accidentally introduced into a commit** — the pre-commit hook runs `scan --staged` automatically:
228
+ **A secret accidentally introduced into a commit:**
229
229
 
230
230
  ```bash
231
231
  envshield hook install
232
232
  ```
233
233
 
234
+ The pre-commit hook runs `scan --staged --enforce`, applying classification-based enforcement with interactive override for high-confidence findings on local commits, and strict blocking in CI. See [Secret safety](#secret-safety) for enforcement behavior details.
235
+
234
236
  In CI, diff against the merge-base, not the base branch's current tip (which can move while the PR is open):
235
237
 
236
238
  ```yaml
@@ -407,10 +409,84 @@ Source-code discovery (what powers `undeclared` and `explain`'s "used in source"
407
409
 
408
410
  ## Secret safety
409
411
 
410
- A supporting check alongside the contract, not the product itself. `secret = true` on a field tells EnvShield (and `setup`, and code generation) that it's sensitive — the schema records that fact, never the actual value. On top of that:
412
+ A supporting check alongside the contract, not the product itself. `secret = true` on a field tells EnvShield (and `setup`, and code generation) that it's sensitive — the schema records that fact, never the actual value.
413
+
414
+ ### Secret scanning
415
+
416
+ **`envshield scan`** looks for hardcoded secrets by pattern (Stripe, AWS, GitHub tokens, and more), and for env-var reads the schema doesn't declare, in one pass. Values are always redacted in output — a Stripe *publishable* key (`pk_...`) is never flagged, since detection matches the secret-key pattern (`sk_...`) by shape, not the variable's name.
417
+
418
+ ### Context-aware classification
419
+
420
+ Python candidates are analyzed for syntactic context to distinguish code patterns from secret values:
421
+
422
+ ```python
423
+ # Classified as code (suppressed):
424
+ client(key=CONFIG_CONSTANT)
425
+
426
+
427
+ def handler(api_key: str): ...
428
+
429
+
430
+ # Classified as likely secret (detected):
431
+ client(key="sk_live_abc123...")
432
+ ```
433
+
434
+ This reduces false positives on ordinary code patterns without broadly suppressing secret-shaped values. Classification is lightweight and syntactic — it recognizes type annotations, function arguments, and identifier references, but doesn't perform semantic analysis beyond what the tokenizer can see.
435
+
436
+ ### Git hook enforcement
437
+
438
+ **`envshield hook install`** wires secret scanning into your Git workflow with classification-based enforcement:
439
+
440
+ ```bash
441
+ envshield hook install
442
+ ```
443
+
444
+ The pre-commit hook runs `scan --staged --enforce`, which scans only staged content and applies an enforcement policy based on classification confidence.
445
+
446
+ #### Enforcement behavior
447
+
448
+ | Finding type | Interactive (local commit) | Non-interactive (CI) |
449
+ |---|---|---|
450
+ | No findings | Allow | Allow |
451
+ | Clearly code-shaped | Suppressed | Suppressed |
452
+ | Ambiguous | Block | Block |
453
+ | High-confidence secret | Block + explicit override | Block |
454
+
455
+ **Clearly code-shaped** candidates (type annotations, function keyword arguments, etc.) are suppressed by the classifier and don't produce findings.
456
+
457
+ **Ambiguous** findings — insufficient context to confidently classify — block without override in both modes.
458
+
459
+ **High-confidence secrets** (string literals matching secret patterns) block by default but offer an interactive override for local commits:
460
+
461
+ ```
462
+ ⚠ EnvShield found potential secrets
463
+
464
+ HIGH CONFIDENCE
465
+ src/config.py:42 Generic API Key [REDACTED (30 chars)]
466
+
467
+ [1] Abort commit
468
+ [2] Commit anyway
469
+
470
+ Choice: 2
471
+
472
+ Type COMMIT ANYWAY to confirm: COMMIT ANYWAY
473
+
474
+ Proceeding with commit. The finding remains in the repository history.
475
+ ```
476
+
477
+ The override requires exact confirmation text — `yes` or `Y` won't work. EOF (Ctrl+D) or Ctrl+C aborts. Raw secret values are never displayed in enforcement output.
478
+
479
+ In CI or other non-interactive contexts, high-confidence findings block with no prompt — the commit is refused, and the scan exits non-zero.
480
+
481
+ #### Scanning modes
482
+
483
+ ```bash
484
+ envshield scan # Audit entire codebase
485
+ envshield scan --staged # Scan only staged Git content
486
+ envshield scan --staged --enforce # Staged scan + enforcement policy
487
+ ```
411
488
 
412
- - **`envshield scan`** looks for hardcoded secrets by pattern (Stripe, AWS, GitHub tokens, and more), and for env-var reads the schema doesn't declare, in one pass. Values are always redacted in output — a Stripe *publishable* key (`pk_...`) is never flagged, since detection matches the secret-key pattern (`sk_...`) by shape, not the variable's name. Python candidates are context-aware: code-shaped patterns like `key=CONSTANT` or `api_key: str` may be suppressed when they're clearly not secret values, while secret-shaped string literals remain detectable regardless of context.
413
- - **`envshield hook install`** wires `scan --staged` into a pre-commit hook, so a real secret is caught before it's committed, not after.
489
+ The pre-commit hook uses the third form. `scan` without flags is for repository-wide audits, not commit-time enforcement.
414
490
 
415
491
  ---
416
492
 
@@ -0,0 +1 @@
1
+ __version__ = "4.7.4"
@@ -1128,6 +1128,12 @@ def _render_dependency_change_table(
1128
1128
  console.print(
1129
1129
  "\n[bold red]New source dependencies are missing from the contract.[/bold red]"
1130
1130
  )
1131
+ console.print(
1132
+ "[bold]Suggestion:[/bold] Review these -- not every read belongs "
1133
+ "in the contract. For the ones that do, add them to "
1134
+ "'env.schema.toml' (hand-edited, not generated). Re-run "
1135
+ "'envshield undeclared' to confirm."
1136
+ )
1131
1137
 
1132
1138
 
1133
1139
  def _print_dependency_error(
@@ -437,6 +437,22 @@ def add_service(
437
437
  entry["config_source"] = config_source
438
438
  services[name] = entry
439
439
 
440
+ if (
441
+ entry.get("local_file")
442
+ and not entry.get("example_file")
443
+ and not entry["local_file"].endswith(".py")
444
+ and os.path.basename(entry["local_file"]) != ".env"
445
+ ):
446
+ console.print(
447
+ f"[yellow]Note:[/yellow] service '{name}' overrides local_file to "
448
+ f"'{entry['local_file']}' but not example_file -- the template "
449
+ "will still default to '.env.example' in this service's "
450
+ "directory. If this local_file follows a different naming "
451
+ "convention (e.g. '.env.local' pairs with '.env.local.example'), "
452
+ "set example_file explicitly too, or Template Sync will look at "
453
+ "the wrong file."
454
+ )
455
+
440
456
  with open(CONFIG_FILE_NAME, "w") as f:
441
457
  yaml.dump(config, f, sort_keys=False, indent=2)
442
458
 
@@ -145,24 +145,70 @@ class HooksManager:
145
145
  except EnvShieldException as e:
146
146
  console.print(f"[bold yellow]⚠️ Warning:[/] {e}")
147
147
 
148
+ def _hook_status_line(
149
+ self, hooks_dir: str, filename: str, label: str, generator
150
+ ) -> str:
151
+ """
152
+ Describes one installed hook: missing, up to date, or stale --
153
+ present, and still carrying EnvShield's marker/shape, but no longer
154
+ byte-for-byte what EnvShield would generate right now (e.g. a
155
+ service was added to envshield.yml after this hook was installed,
156
+ so it's missing that service's block; or the file was hand-edited).
157
+
158
+ This is purely informational: unlike `are_hooks_installed` (used by
159
+ `hook install`/`uninstall`'s exact-match ownership check -- see
160
+ BL-119/BL-120), staleness here never gates or changes what those
161
+ commands do. A stale hook is not "not installed" -- it still runs
162
+ and still protects whatever it was generated to protect; this only
163
+ surfaces that it may no longer match the project's current config,
164
+ so nothing here needed to touch that exact-match safety invariant.
165
+ """
166
+ path = os.path.join(hooks_dir, filename)
167
+ if not os.path.exists(path):
168
+ return f"[dim]✗ {label}[/dim]"
169
+
170
+ try:
171
+ with open(path, "r") as f:
172
+ installed_content = f.read()
173
+ except OSError:
174
+ return f"[green]✓ {label}[/green]"
175
+
176
+ if installed_content == generator():
177
+ return f"[green]✓ {label}[/green]"
178
+
179
+ return (
180
+ f"[yellow]✓ {label} (stale -- doesn't match what EnvShield would "
181
+ "generate now; run 'envshield hook install --yes' to refresh)[/yellow]"
182
+ )
183
+
148
184
  def print_hook_status(self) -> None:
149
185
  """Print the current status of installed hooks."""
150
186
  if not self.git_root:
151
187
  console.print("[dim]Not in a git repository.[/dim]")
152
188
  return
153
189
 
154
- pre_commit, post_merge = self.are_hooks_installed()
190
+ # Import here to avoid circular imports (same pattern as
191
+ # _do_install_hooks above).
192
+ from . import scanner
155
193
 
156
- status = []
157
- if pre_commit:
158
- status.append("[green]✓ pre-commit hook[/green] (secret scanning)")
159
- else:
160
- status.append("[dim]✗ pre-commit hook[/dim]")
194
+ hooks_dir = git_utils.get_hooks_dir() or os.path.join(
195
+ self.git_root, ".git", "hooks"
196
+ )
161
197
 
162
- if post_merge:
163
- status.append("[green]✓ post-merge hook[/green] (config change detection)")
164
- else:
165
- status.append("[dim]✗ post-merge hook[/dim]")
198
+ status = [
199
+ self._hook_status_line(
200
+ hooks_dir,
201
+ "pre-commit",
202
+ "pre-commit hook (secret scanning)",
203
+ scanner._generate_pre_commit_hook_content,
204
+ ),
205
+ self._hook_status_line(
206
+ hooks_dir,
207
+ "post-merge",
208
+ "post-merge hook (config change detection)",
209
+ scanner._generate_post_merge_hook_content,
210
+ ),
211
+ ]
166
212
 
167
213
  console.print("\n[bold]Git Hooks:[/bold]")
168
214
  for s in status:
@@ -1387,6 +1387,12 @@ def run_scan(
1387
1387
  finding["variable_name"],
1388
1388
  )
1389
1389
  console.print(undeclared_table)
1390
+ console.print(
1391
+ "\n[bold]Suggestion:[/bold] Review these -- not every read "
1392
+ "belongs in the contract. For the ones that do, add them to "
1393
+ "'env.schema.toml' (hand-edited, not generated), then re-stage "
1394
+ "and commit again."
1395
+ )
1390
1396
 
1391
1397
  console.print(
1392
1398
  "\n[bold red]Commit aborted. Please fix the issues above before committing.[/bold red]"
@@ -1438,7 +1444,10 @@ def run_scan(
1438
1444
  )
1439
1445
  console.print(undeclared_table)
1440
1446
  console.print(
1441
- "\n[bold]Suggestion:[/bold] Please add these variables to your 'env.schema.toml' to maintain your configuration contract."
1447
+ "\n[bold]Suggestion:[/bold] Review these -- not every read belongs "
1448
+ "in the contract. For the ones that do, add them to "
1449
+ "'env.schema.toml' (hand-edited, not generated). Re-run "
1450
+ "'envshield scan' to confirm."
1442
1451
  )
1443
1452
 
1444
1453
  # A skip means eligible content was never actually inspected -- this
@@ -291,13 +291,30 @@ def detect_env_style(service_dir: str) -> Dict[str, Optional[str]]:
291
291
  ('<service_dir>/.env' / '<service_dir>/.env.example') -- i.e. for the
292
292
  python format, when the real dotenv file found isn't literally named
293
293
  '.env', or when only a template was found under a non-standard name.
294
+
295
+ When the real file isn't '.env' (e.g. Next.js's '.env.local'), its
296
+ paired template conventionally isn't '.env.example' either -- it's
297
+ '<real_file>.example' ('.env.local' -> '.env.local.example'). Without
298
+ checking for that file specifically, every downstream command
299
+ (doctor's Template Sync, 'schema sync', the pre-commit hook's
300
+ post-schema-change check) would keep resolving the untouched default
301
+ '.env.example' instead, reporting a real, in-sync template as missing.
302
+ This only sets `example_file` when that paired file is actually found
303
+ on disk -- evidence-gated, like the rest of this module's detection,
304
+ not guessed from the name alone.
294
305
  """
295
306
  real_file = _find_real_dotenv_file(service_dir)
296
307
  if real_file:
308
+ local_file = None if os.path.basename(real_file) == ".env" else real_file
309
+ example_file = None
310
+ if local_file:
311
+ paired_template = real_file + ".example"
312
+ if os.path.isfile(paired_template):
313
+ example_file = paired_template
297
314
  return {
298
315
  "format": "dotenv",
299
- "local_file": None if os.path.basename(real_file) == ".env" else real_file,
300
- "example_file": None,
316
+ "local_file": local_file,
317
+ "example_file": example_file,
301
318
  }
302
319
 
303
320
  template_file = _find_dotenv_template(service_dir)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envshield
3
- Version: 4.7.3
3
+ Version: 4.7.4
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
@@ -270,7 +270,7 @@ Every command above documents its own options with `--help`; the full reference
270
270
 
271
271
  ## Git / CI workflow
272
272
 
273
- This is where the schema stops being documentation and starts being enforcement. Three concrete things EnvShield catches in a PR:
273
+ This is where the schema stops being documentation and starts being enforcement. Three concrete things EnvShield catches before a change reaches production:
274
274
 
275
275
  **A new environment variable used in code but missing from the schema:**
276
276
 
@@ -284,12 +284,14 @@ envshield undeclared origin/main HEAD --service api
284
284
  envshield schema diff origin/main HEAD --service api
285
285
  ```
286
286
 
287
- **A secret accidentally introduced into a commit** — the pre-commit hook runs `scan --staged` automatically:
287
+ **A secret accidentally introduced into a commit:**
288
288
 
289
289
  ```bash
290
290
  envshield hook install
291
291
  ```
292
292
 
293
+ The pre-commit hook runs `scan --staged --enforce`, applying classification-based enforcement with interactive override for high-confidence findings on local commits, and strict blocking in CI. See [Secret safety](#secret-safety) for enforcement behavior details.
294
+
293
295
  In CI, diff against the merge-base, not the base branch's current tip (which can move while the PR is open):
294
296
 
295
297
  ```yaml
@@ -466,10 +468,84 @@ Source-code discovery (what powers `undeclared` and `explain`'s "used in source"
466
468
 
467
469
  ## Secret safety
468
470
 
469
- A supporting check alongside the contract, not the product itself. `secret = true` on a field tells EnvShield (and `setup`, and code generation) that it's sensitive — the schema records that fact, never the actual value. On top of that:
471
+ A supporting check alongside the contract, not the product itself. `secret = true` on a field tells EnvShield (and `setup`, and code generation) that it's sensitive — the schema records that fact, never the actual value.
472
+
473
+ ### Secret scanning
474
+
475
+ **`envshield scan`** looks for hardcoded secrets by pattern (Stripe, AWS, GitHub tokens, and more), and for env-var reads the schema doesn't declare, in one pass. Values are always redacted in output — a Stripe *publishable* key (`pk_...`) is never flagged, since detection matches the secret-key pattern (`sk_...`) by shape, not the variable's name.
476
+
477
+ ### Context-aware classification
478
+
479
+ Python candidates are analyzed for syntactic context to distinguish code patterns from secret values:
480
+
481
+ ```python
482
+ # Classified as code (suppressed):
483
+ client(key=CONFIG_CONSTANT)
484
+
485
+
486
+ def handler(api_key: str): ...
487
+
488
+
489
+ # Classified as likely secret (detected):
490
+ client(key="sk_live_abc123...")
491
+ ```
492
+
493
+ This reduces false positives on ordinary code patterns without broadly suppressing secret-shaped values. Classification is lightweight and syntactic — it recognizes type annotations, function arguments, and identifier references, but doesn't perform semantic analysis beyond what the tokenizer can see.
494
+
495
+ ### Git hook enforcement
496
+
497
+ **`envshield hook install`** wires secret scanning into your Git workflow with classification-based enforcement:
498
+
499
+ ```bash
500
+ envshield hook install
501
+ ```
502
+
503
+ The pre-commit hook runs `scan --staged --enforce`, which scans only staged content and applies an enforcement policy based on classification confidence.
504
+
505
+ #### Enforcement behavior
506
+
507
+ | Finding type | Interactive (local commit) | Non-interactive (CI) |
508
+ |---|---|---|
509
+ | No findings | Allow | Allow |
510
+ | Clearly code-shaped | Suppressed | Suppressed |
511
+ | Ambiguous | Block | Block |
512
+ | High-confidence secret | Block + explicit override | Block |
513
+
514
+ **Clearly code-shaped** candidates (type annotations, function keyword arguments, etc.) are suppressed by the classifier and don't produce findings.
515
+
516
+ **Ambiguous** findings — insufficient context to confidently classify — block without override in both modes.
517
+
518
+ **High-confidence secrets** (string literals matching secret patterns) block by default but offer an interactive override for local commits:
519
+
520
+ ```
521
+ ⚠ EnvShield found potential secrets
522
+
523
+ HIGH CONFIDENCE
524
+ src/config.py:42 Generic API Key [REDACTED (30 chars)]
525
+
526
+ [1] Abort commit
527
+ [2] Commit anyway
528
+
529
+ Choice: 2
530
+
531
+ Type COMMIT ANYWAY to confirm: COMMIT ANYWAY
532
+
533
+ Proceeding with commit. The finding remains in the repository history.
534
+ ```
535
+
536
+ The override requires exact confirmation text — `yes` or `Y` won't work. EOF (Ctrl+D) or Ctrl+C aborts. Raw secret values are never displayed in enforcement output.
537
+
538
+ In CI or other non-interactive contexts, high-confidence findings block with no prompt — the commit is refused, and the scan exits non-zero.
539
+
540
+ #### Scanning modes
541
+
542
+ ```bash
543
+ envshield scan # Audit entire codebase
544
+ envshield scan --staged # Scan only staged Git content
545
+ envshield scan --staged --enforce # Staged scan + enforcement policy
546
+ ```
470
547
 
471
- - **`envshield scan`** looks for hardcoded secrets by pattern (Stripe, AWS, GitHub tokens, and more), and for env-var reads the schema doesn't declare, in one pass. Values are always redacted in output — a Stripe *publishable* key (`pk_...`) is never flagged, since detection matches the secret-key pattern (`sk_...`) by shape, not the variable's name. Python candidates are context-aware: code-shaped patterns like `key=CONSTANT` or `api_key: str` may be suppressed when they're clearly not secret values, while secret-shaped string literals remain detectable regardless of context.
472
- - **`envshield hook install`** wires `scan --staged` into a pre-commit hook, so a real secret is caught before it's committed, not after.
548
+ The pre-commit hook uses the third form. `scan` without flags is for repository-wide audits, not commit-time enforcement.
473
549
 
474
550
  ---
475
551
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "envshield"
7
- version = "4.7.3" # must be a string for PEP 621 compliance
7
+ version = "4.7.4" # must be a string for PEP 621 compliance
8
8
  description = "EnvShield: environment variables as a version-controlled configuration contract."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1 +0,0 @@
1
- __version__ = "4.7.3"
File without changes
File without changes
File without changes