envshield 4.7.0__tar.gz → 4.7.3__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 (55) hide show
  1. {envshield-4.7.0 → envshield-4.7.3}/PKG-INFO +67 -3
  2. {envshield-4.7.0 → envshield-4.7.3}/README.md +66 -2
  3. envshield-4.7.3/envshield/__init__.py +1 -0
  4. envshield-4.7.3/envshield/classifier/__init__.py +12 -0
  5. envshield-4.7.3/envshield/classifier/context_classifier.py +313 -0
  6. envshield-4.7.3/envshield/classifier/tokenizer.py +299 -0
  7. {envshield-4.7.0 → envshield-4.7.3}/envshield/cli.py +203 -29
  8. {envshield-4.7.0 → envshield-4.7.3}/envshield/config/manager.py +108 -13
  9. envshield-4.7.3/envshield/core/dependency_diff.py +235 -0
  10. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/discovery.py +32 -13
  11. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/doctor.py +7 -7
  12. envshield-4.7.3/envshield/core/enforcement.py +191 -0
  13. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/exceptions.py +15 -0
  14. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/explain.py +12 -11
  15. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/hooks_manager.py +13 -2
  16. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/importer.py +13 -0
  17. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/scanner.py +700 -35
  18. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/schema_manager.py +48 -25
  19. {envshield-4.7.0 → envshield-4.7.3}/envshield/parsers/_docker_compose.py +88 -11
  20. envshield-4.7.3/envshield/parsers/factory.py +95 -0
  21. {envshield-4.7.0 → envshield-4.7.3}/envshield.egg-info/PKG-INFO +67 -3
  22. {envshield-4.7.0 → envshield-4.7.3}/envshield.egg-info/SOURCES.txt +4 -0
  23. {envshield-4.7.0 → envshield-4.7.3}/pyproject.toml +1 -1
  24. envshield-4.7.0/envshield/__init__.py +0 -1
  25. envshield-4.7.0/envshield/core/dependency_diff.py +0 -112
  26. envshield-4.7.0/envshield/parsers/factory.py +0 -52
  27. {envshield-4.7.0 → envshield-4.7.3}/LICENSE.md +0 -0
  28. {envshield-4.7.0 → envshield-4.7.3}/envshield/__main__.py +0 -0
  29. {envshield-4.7.0 → envshield-4.7.3}/envshield/config/__init__.py +0 -0
  30. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/__init__.py +0 -0
  31. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/contract_diff.py +0 -0
  32. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/dependency_snapshot.py +0 -0
  33. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/file_updater.py +0 -0
  34. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/generator.py +0 -0
  35. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/inspector.py +0 -0
  36. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/schema_snapshot.py +0 -0
  37. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/schema_types.py +0 -0
  38. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/service_discovery.py +0 -0
  39. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/service_manager.py +0 -0
  40. {envshield-4.7.0 → envshield-4.7.3}/envshield/core/setup_manager.py +0 -0
  41. {envshield-4.7.0 → envshield-4.7.3}/envshield/parsers/__init__.py +0 -0
  42. {envshield-4.7.0 → envshield-4.7.3}/envshield/parsers/_base.py +0 -0
  43. {envshield-4.7.0 → envshield-4.7.3}/envshield/parsers/_deployment.py +0 -0
  44. {envshield-4.7.0 → envshield-4.7.3}/envshield/parsers/_dotenv.py +0 -0
  45. {envshield-4.7.0 → envshield-4.7.3}/envshield/parsers/_kubernetes.py +0 -0
  46. {envshield-4.7.0 → envshield-4.7.3}/envshield/parsers/_python.py +0 -0
  47. {envshield-4.7.0 → envshield-4.7.3}/envshield/state.py +0 -0
  48. {envshield-4.7.0 → envshield-4.7.3}/envshield/utils/__init__.py +0 -0
  49. {envshield-4.7.0 → envshield-4.7.3}/envshield/utils/git_utils.py +0 -0
  50. {envshield-4.7.0 → envshield-4.7.3}/envshield/utils/paths.py +0 -0
  51. {envshield-4.7.0 → envshield-4.7.3}/envshield.egg-info/dependency_links.txt +0 -0
  52. {envshield-4.7.0 → envshield-4.7.3}/envshield.egg-info/entry_points.txt +0 -0
  53. {envshield-4.7.0 → envshield-4.7.3}/envshield.egg-info/requires.txt +0 -0
  54. {envshield-4.7.0 → envshield-4.7.3}/envshield.egg-info/top_level.txt +0 -0
  55. {envshield-4.7.0 → envshield-4.7.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envshield
3
- Version: 4.7.0
3
+ Version: 4.7.3
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
@@ -308,7 +308,25 @@ jobs:
308
308
  envshield undeclared "$BASE" HEAD
309
309
  ```
310
310
 
311
- Both commands exit non-zero on a real finding — nothing beyond the exit code is required to gate a PR. Add `--json` if you want to post a custom summary instead.
311
+ Both commands exit non-zero on a real finding, and also on an error that prevented evaluation (a bad revision, a schema that failed to parse) — never a silent "clean" for something it couldn't actually check. Add `--json` if you want to post a custom summary instead.
312
+
313
+ ### SARIF output (`undeclared --sarif`)
314
+
315
+ `envshield undeclared` also supports `--sarif` — a standard [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/) log, for GitHub Code Scanning or any other SARIF-consuming CI tool, so a missing declaration is annotated on its exact file and line rather than only appearing in a CI log:
316
+
317
+ ```yaml
318
+ - run: |
319
+ BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
320
+ envshield undeclared "$BASE" HEAD --sarif > envshield.sarif
321
+ continue-on-error: true # upload the report even when it found something
322
+ - uses: github/codeql-action/upload-sarif@v3
323
+ with:
324
+ sarif_file: envshield.sarif
325
+ ```
326
+
327
+ `--sarif` only exists for `undeclared`, deliberately: each finding there has a real file and line to point at. `schema diff` findings describe the *contract* (a variable added, removed, or changed) rather than a source location, so forcing them into SARIF would mean inventing a line number that isn't real — use its `--json`/exit-code output instead, or annotate it yourself (e.g. a `jq` step over the JSON, emitting `::error::` workflow commands).
328
+
329
+ An error that prevents evaluating a service (a bad revision, a schema that fails to parse) is never turned into a fabricated SARIF result — it's reported as a SARIF `invocation` with `executionSuccessful: false`, so a consumer can't mistake "couldn't check" for "checked and clean."
312
330
 
313
331
  ---
314
332
 
@@ -410,6 +428,34 @@ A few things worth knowing:
410
428
 
411
429
  ---
412
430
 
431
+ ## Docker Compose base + override layers (`files:`)
432
+
433
+ A manifest registration normally points at one file:
434
+
435
+ ```yaml
436
+ manifests:
437
+ - file: docker-compose.yml
438
+ containers:
439
+ api: api
440
+ ```
441
+
442
+ A real Compose project commonly splits its configuration across a base file plus an override that Compose applies automatically (`docker-compose.yml` + `docker-compose.override.yml`, or any other `-f a -f b` combination). Registering only the base misses whatever the override adds; registering the override alone reports the base's own variables as falsely missing, since an override file is deliberately just a delta. `files:` registers the whole ordered layer set as **one** logical manifest instead:
443
+
444
+ ```yaml
445
+ manifests:
446
+ - files:
447
+ - docker-compose.yml
448
+ - docker-compose.override.yml
449
+ containers:
450
+ api: api
451
+ ```
452
+
453
+ Order matters — later files override earlier ones, exactly like `docker compose -f docker-compose.yml -f docker-compose.override.yml` itself. `check`/`doctor`/`explain` all validate the merged result as a single source, under one combined label (`"docker-compose.yml + docker-compose.override.yml"`).
454
+
455
+ This is Compose-specific merge behavior for the fields EnvShield already understands (`environment:`, `env_file:`) — **not** a general YAML merge engine. Volumes, networks, ports, build, and every other Compose field are never inspected or merged. `files:` is unrelated to `completeness: union` above: union combines *presence across different kinds of source* (a local file and a manifest); `files:` merges *values across ordered layers of one Compose source*. A manifest entry can declare `file:` or `files:`, never both.
456
+
457
+ ---
458
+
413
459
  ## Supported languages / discovery
414
460
 
415
461
  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.
@@ -422,11 +468,29 @@ Source-code discovery (what powers `undeclared` and `explain`'s "used in source"
422
468
 
423
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:
424
470
 
425
- - **`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.
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.
426
472
  - **`envshield hook install`** wires `scan --staged` into a pre-commit hook, so a real secret is caught before it's committed, not after.
427
473
 
428
474
  ---
429
475
 
476
+ ## Removing EnvShield
477
+
478
+ ```bash
479
+ envshield uninstall
480
+ ```
481
+
482
+ Removes EnvShield's Git hook integration while preserving your project configuration — it does not delete `envshield.yml`, any `env.schema.toml`, `.env.example`, any registered local configuration file (`.env`, a Python config module, etc.), `.gitignore`, or any code `envshield generate` has produced, regardless of whether EnvShield originally created any of it. Those are your project's own configuration contract and application config, not disposable installation artifacts.
483
+
484
+ The only thing `uninstall` ever deletes is a Git hook whose content exactly matches what EnvShield would generate right now — the same ownership rule `hook remove` already uses. A hand-modified hook, a foreign hook (even one that happens to contain EnvShield's marker comment), or an EnvShield hook that's gone stale relative to your current `envshield.yml` (e.g. after registering a new service) is left in place and reported, never deleted or silently regenerated.
485
+
486
+ ```bash
487
+ envshield uninstall --yes # skip the confirmation prompt
488
+ ```
489
+
490
+ `--yes` only skips the prompt — it never overrides the ownership check; a modified, foreign, or stale hook is preserved with `--yes` exactly as it is interactively. There is no `--force` and no `--purge`: `uninstall` is intentionally not a "delete everything EnvShield ever created" command. To remove EnvShield's configuration from a project entirely, delete `envshield.yml` and the schema files yourself once you're sure you no longer need them.
491
+
492
+ ---
493
+
430
494
  ## Known limitations
431
495
 
432
496
  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:
@@ -249,7 +249,25 @@ jobs:
249
249
  envshield undeclared "$BASE" HEAD
250
250
  ```
251
251
 
252
- Both commands exit non-zero on a real finding — nothing beyond the exit code is required to gate a PR. Add `--json` if you want to post a custom summary instead.
252
+ Both commands exit non-zero on a real finding, and also on an error that prevented evaluation (a bad revision, a schema that failed to parse) — never a silent "clean" for something it couldn't actually check. Add `--json` if you want to post a custom summary instead.
253
+
254
+ ### SARIF output (`undeclared --sarif`)
255
+
256
+ `envshield undeclared` also supports `--sarif` — a standard [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/) log, for GitHub Code Scanning or any other SARIF-consuming CI tool, so a missing declaration is annotated on its exact file and line rather than only appearing in a CI log:
257
+
258
+ ```yaml
259
+ - run: |
260
+ BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
261
+ envshield undeclared "$BASE" HEAD --sarif > envshield.sarif
262
+ continue-on-error: true # upload the report even when it found something
263
+ - uses: github/codeql-action/upload-sarif@v3
264
+ with:
265
+ sarif_file: envshield.sarif
266
+ ```
267
+
268
+ `--sarif` only exists for `undeclared`, deliberately: each finding there has a real file and line to point at. `schema diff` findings describe the *contract* (a variable added, removed, or changed) rather than a source location, so forcing them into SARIF would mean inventing a line number that isn't real — use its `--json`/exit-code output instead, or annotate it yourself (e.g. a `jq` step over the JSON, emitting `::error::` workflow commands).
269
+
270
+ An error that prevents evaluating a service (a bad revision, a schema that fails to parse) is never turned into a fabricated SARIF result — it's reported as a SARIF `invocation` with `executionSuccessful: false`, so a consumer can't mistake "couldn't check" for "checked and clean."
253
271
 
254
272
  ---
255
273
 
@@ -351,6 +369,34 @@ A few things worth knowing:
351
369
 
352
370
  ---
353
371
 
372
+ ## Docker Compose base + override layers (`files:`)
373
+
374
+ A manifest registration normally points at one file:
375
+
376
+ ```yaml
377
+ manifests:
378
+ - file: docker-compose.yml
379
+ containers:
380
+ api: api
381
+ ```
382
+
383
+ A real Compose project commonly splits its configuration across a base file plus an override that Compose applies automatically (`docker-compose.yml` + `docker-compose.override.yml`, or any other `-f a -f b` combination). Registering only the base misses whatever the override adds; registering the override alone reports the base's own variables as falsely missing, since an override file is deliberately just a delta. `files:` registers the whole ordered layer set as **one** logical manifest instead:
384
+
385
+ ```yaml
386
+ manifests:
387
+ - files:
388
+ - docker-compose.yml
389
+ - docker-compose.override.yml
390
+ containers:
391
+ api: api
392
+ ```
393
+
394
+ Order matters — later files override earlier ones, exactly like `docker compose -f docker-compose.yml -f docker-compose.override.yml` itself. `check`/`doctor`/`explain` all validate the merged result as a single source, under one combined label (`"docker-compose.yml + docker-compose.override.yml"`).
395
+
396
+ This is Compose-specific merge behavior for the fields EnvShield already understands (`environment:`, `env_file:`) — **not** a general YAML merge engine. Volumes, networks, ports, build, and every other Compose field are never inspected or merged. `files:` is unrelated to `completeness: union` above: union combines *presence across different kinds of source* (a local file and a manifest); `files:` merges *values across ordered layers of one Compose source*. A manifest entry can declare `file:` or `files:`, never both.
397
+
398
+ ---
399
+
354
400
  ## Supported languages / discovery
355
401
 
356
402
  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.
@@ -363,11 +409,29 @@ Source-code discovery (what powers `undeclared` and `explain`'s "used in source"
363
409
 
364
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:
365
411
 
366
- - **`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.
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.
367
413
  - **`envshield hook install`** wires `scan --staged` into a pre-commit hook, so a real secret is caught before it's committed, not after.
368
414
 
369
415
  ---
370
416
 
417
+ ## Removing EnvShield
418
+
419
+ ```bash
420
+ envshield uninstall
421
+ ```
422
+
423
+ Removes EnvShield's Git hook integration while preserving your project configuration — it does not delete `envshield.yml`, any `env.schema.toml`, `.env.example`, any registered local configuration file (`.env`, a Python config module, etc.), `.gitignore`, or any code `envshield generate` has produced, regardless of whether EnvShield originally created any of it. Those are your project's own configuration contract and application config, not disposable installation artifacts.
424
+
425
+ The only thing `uninstall` ever deletes is a Git hook whose content exactly matches what EnvShield would generate right now — the same ownership rule `hook remove` already uses. A hand-modified hook, a foreign hook (even one that happens to contain EnvShield's marker comment), or an EnvShield hook that's gone stale relative to your current `envshield.yml` (e.g. after registering a new service) is left in place and reported, never deleted or silently regenerated.
426
+
427
+ ```bash
428
+ envshield uninstall --yes # skip the confirmation prompt
429
+ ```
430
+
431
+ `--yes` only skips the prompt — it never overrides the ownership check; a modified, foreign, or stale hook is preserved with `--yes` exactly as it is interactively. There is no `--force` and no `--purge`: `uninstall` is intentionally not a "delete everything EnvShield ever created" command. To remove EnvShield's configuration from a project entirely, delete `envshield.yml` and the schema files yourself once you're sure you no longer need them.
432
+
433
+ ---
434
+
371
435
  ## Known limitations
372
436
 
373
437
  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:
@@ -0,0 +1 @@
1
+ __version__ = "4.7.3"
@@ -0,0 +1,12 @@
1
+ # envshield/classifier/__init__.py
2
+ """
3
+ Context classifier for false-positive reduction in secret scanning.
4
+
5
+ This module provides the tokenizer and context classifier used to distinguish
6
+ code patterns (function keyword arguments, type annotations, destructuring)
7
+ from actual secret values, reducing false positives in the Generic API Key
8
+ detector.
9
+
10
+ See Milestone 2 (tokenizer) and Milestone 3 (classifier) documentation for
11
+ design rationale and limitations.
12
+ """
@@ -0,0 +1,313 @@
1
+ """
2
+ Context Classifier for EnvShield Secret Scanner
3
+
4
+ Three-level classification model optimized for Git-hook blocking behavior:
5
+ - LIKELY_SECRET: High confidence secret value (e.g., string literal)
6
+ - LIKELY_CODE: High confidence code pattern (e.g., type annotation, function arg)
7
+ - AMBIGUOUS: Uncertain context (preserve uncertainty for policy layer)
8
+
9
+ Key principle: Reason about syntactic role, not identifier names.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from enum import Enum
14
+ from typing import Any, Dict, Tuple
15
+
16
+ # Import tokenizer from same module
17
+ from .tokenizer import extract_context
18
+
19
+
20
+ class Classification(Enum):
21
+ """Three-level classification for secret candidates."""
22
+
23
+ LIKELY_SECRET = "likely_secret" # High confidence: looks like a secret value
24
+ LIKELY_CODE = "likely_code" # High confidence: syntactic code pattern
25
+ AMBIGUOUS = "ambiguous" # Uncertain: could be either
26
+
27
+
28
+ @dataclass
29
+ class ClassificationResult:
30
+ """
31
+ Result of classifying a secret candidate.
32
+
33
+ Attributes:
34
+ classification: The classification level (LIKELY_SECRET, LIKELY_CODE, AMBIGUOUS)
35
+ confidence: Confidence level ("HIGH", "MEDIUM", "LOW")
36
+ reason: Human-readable explanation of the classification
37
+ rule_name: Name of the rule that matched (for debugging/testing)
38
+ context_summary: Key context fields (never includes secret values)
39
+ """
40
+
41
+ classification: Classification
42
+ confidence: str
43
+ reason: str
44
+ rule_name: str
45
+ context_summary: Dict[str, Any]
46
+
47
+ def __repr__(self) -> str:
48
+ return (
49
+ f"ClassificationResult("
50
+ f"classification={self.classification.value}, "
51
+ f"confidence={self.confidence}, "
52
+ f"reason='{self.reason}', "
53
+ f"rule_name={self.rule_name})"
54
+ )
55
+
56
+
57
+ class ContextClassifier:
58
+ """
59
+ Classifies regex match candidates based on syntactic context.
60
+
61
+ Three-level classification model:
62
+ - LIKELY_SECRET: High confidence secret value (e.g., string literal)
63
+ - LIKELY_CODE: High confidence code pattern (e.g., type annotation)
64
+ - AMBIGUOUS: Uncertain context
65
+
66
+ Designed for Git-hook blocking: preserves uncertainty for policy layer.
67
+
68
+ Example:
69
+ classifier = ContextClassifier()
70
+
71
+ # Function keyword argument (identifier) → LIKELY_CODE
72
+ result = classifier.classify("func(key=CONSTANT,)", 9, 17)
73
+ assert result.classification == Classification.LIKELY_CODE
74
+
75
+ # Function argument with string literal → LIKELY_SECRET
76
+ result = classifier.classify("func(key='secret')", 9, 17)
77
+ assert result.classification == Classification.LIKELY_SECRET
78
+
79
+ # Type annotation → LIKELY_CODE
80
+ result = classifier.classify("def foo(token: str):", 15, 18)
81
+ assert result.classification == Classification.LIKELY_CODE
82
+ """
83
+
84
+ def classify(
85
+ self, line: str, match_start: int, match_end: int
86
+ ) -> ClassificationResult:
87
+ """
88
+ Classify a regex match candidate based on syntactic context.
89
+
90
+ Args:
91
+ line: Source line containing the match
92
+ match_start: Character offset of match start (0-indexed)
93
+ match_end: Character offset of match end (exclusive)
94
+
95
+ Returns:
96
+ ClassificationResult with classification, confidence, reason, rule name
97
+
98
+ Classification rules (highest priority first):
99
+ 1. String literal → LIKELY_SECRET (HIGH confidence)
100
+ 2. Type annotation → LIKELY_CODE (HIGH confidence)
101
+ 3. Function keyword argument (identifier) → LIKELY_CODE (HIGH confidence)
102
+ 4. Multi-line keyword argument heuristic → LIKELY_CODE (MEDIUM-HIGH confidence)
103
+ 5. Destructuring → LIKELY_CODE (HIGH confidence)
104
+ 6. Identifier reference → LIKELY_CODE (MEDIUM confidence)
105
+ 7. Default → AMBIGUOUS
106
+ """
107
+ # Extract syntactic context from tokenizer
108
+ context = extract_context(line, match_start, match_end)
109
+
110
+ # Rule 1: String literal → LIKELY_SECRET
111
+ if context.is_string_literal:
112
+ return ClassificationResult(
113
+ classification=Classification.LIKELY_SECRET,
114
+ confidence="HIGH",
115
+ reason="string literal value",
116
+ rule_name="string_literal",
117
+ context_summary={"is_string_literal": True},
118
+ )
119
+
120
+ # Rule 2: Type annotation → LIKELY_CODE
121
+ if context.is_type_annotation:
122
+ return ClassificationResult(
123
+ classification=Classification.LIKELY_CODE,
124
+ confidence="HIGH",
125
+ reason="type annotation (: Type syntax)",
126
+ rule_name="type_annotation",
127
+ context_summary={
128
+ "is_type_annotation": True,
129
+ "preceding_operator": context.preceding_operator,
130
+ },
131
+ )
132
+
133
+ # Rule 3: Function keyword argument (identifier) → LIKELY_CODE
134
+ # Example: func(key=CONSTANT,) or func(key=CONSTANT)
135
+ if (
136
+ context.inside_function_call
137
+ and not context.is_string_literal
138
+ and context.preceding_operator == "="
139
+ and context.following_char in (",", ")")
140
+ ):
141
+ return ClassificationResult(
142
+ classification=Classification.LIKELY_CODE,
143
+ confidence="HIGH",
144
+ reason="function keyword argument (identifier, not literal)",
145
+ rule_name="function_keyword_argument",
146
+ context_summary={
147
+ "inside_function_call": True,
148
+ "preceding_operator": "=",
149
+ "following_char": context.following_char,
150
+ "is_string_literal": False,
151
+ },
152
+ )
153
+
154
+ # Rule 4: Multi-line keyword argument heuristic → LIKELY_CODE
155
+ # Catches cases like:
156
+ # key=SESSION_COOKIE_NAME,
157
+ # where opening paren is on previous line
158
+ if (
159
+ not context.is_string_literal
160
+ and context.preceding_operator == "="
161
+ and context.following_char in (",", ")")
162
+ ):
163
+ return ClassificationResult(
164
+ classification=Classification.LIKELY_CODE,
165
+ confidence="MEDIUM-HIGH",
166
+ reason="keyword argument pattern (multi-line heuristic)",
167
+ rule_name="multiline_keyword_argument",
168
+ context_summary={
169
+ "preceding_operator": "=",
170
+ "following_char": context.following_char,
171
+ "is_string_literal": False,
172
+ },
173
+ )
174
+
175
+ # Rule 5: Destructuring → LIKELY_CODE
176
+ # Example: const {key, token} = config
177
+ if context.inside_braces and not context.is_string_literal:
178
+ return ClassificationResult(
179
+ classification=Classification.LIKELY_CODE,
180
+ confidence="HIGH",
181
+ reason="destructuring assignment",
182
+ rule_name="destructuring",
183
+ context_summary={"inside_braces": True, "is_string_literal": False},
184
+ )
185
+
186
+ # Rule 6: REMOVED - Identifier reference overlaps with BL-129
187
+ # Original intent: api_key = DEFAULT_KEY → LIKELY_CODE
188
+ # Problem: This does semantic analysis (is DEFAULT_KEY defined locally?)
189
+ # which requires cross-reference tracking the tokenizer doesn't have.
190
+ # BL-129 already handles this correctly for cases where the identifier
191
+ # has a local definition. For external references (no local definition),
192
+ # they SHOULD remain findings - the classifier was suppressing them
193
+ # incorrectly. Removing this rule to let BL-129 handle all identifier
194
+ # reference cases with proper semantic context.
195
+ #
196
+ # if (
197
+ # not context.is_string_literal
198
+ # and context.preceding_operator == '='
199
+ # and context.following_char not in (',', ')')
200
+ # ):
201
+ # return ClassificationResult(
202
+ # classification=Classification.LIKELY_CODE,
203
+ # confidence="MEDIUM",
204
+ # reason="identifier reference (not literal)",
205
+ # rule_name="identifier_reference",
206
+ # context_summary={
207
+ # "preceding_operator": "=",
208
+ # "is_string_literal": False
209
+ # }
210
+ # )
211
+
212
+ # Rule 7: Default → AMBIGUOUS
213
+ # Insufficient evidence for high-confidence classification
214
+ return ClassificationResult(
215
+ classification=Classification.AMBIGUOUS,
216
+ confidence="LOW",
217
+ reason="insufficient syntactic context for confident classification",
218
+ rule_name="default",
219
+ context_summary={
220
+ "is_string_literal": context.is_string_literal,
221
+ "preceding_operator": context.preceding_operator,
222
+ "following_char": context.following_char,
223
+ "inside_function_call": context.inside_function_call,
224
+ "inside_braces": context.inside_braces,
225
+ "is_type_annotation": context.is_type_annotation,
226
+ },
227
+ )
228
+
229
+ def is_likely_secret(self, line: str, match_start: int, match_end: int) -> bool:
230
+ """
231
+ Convenience method: returns True if classification is LIKELY_SECRET.
232
+
233
+ Args:
234
+ line: Source line containing the match
235
+ match_start: Character offset of match start (0-indexed)
236
+ match_end: Character offset of match end (exclusive)
237
+
238
+ Returns:
239
+ True if classification is LIKELY_SECRET, False otherwise
240
+ """
241
+ return (
242
+ self.classify(line, match_start, match_end).classification
243
+ == Classification.LIKELY_SECRET
244
+ )
245
+
246
+ def is_likely_code(self, line: str, match_start: int, match_end: int) -> bool:
247
+ """
248
+ Convenience method: returns True if classification is LIKELY_CODE.
249
+
250
+ Args:
251
+ line: Source line containing the match
252
+ match_start: Character offset of match start (0-indexed)
253
+ match_end: Character offset of match end (exclusive)
254
+
255
+ Returns:
256
+ True if classification is LIKELY_CODE, False otherwise
257
+ """
258
+ return (
259
+ self.classify(line, match_start, match_end).classification
260
+ == Classification.LIKELY_CODE
261
+ )
262
+
263
+ def is_ambiguous(self, line: str, match_start: int, match_end: int) -> bool:
264
+ """
265
+ Convenience method: returns True if classification is AMBIGUOUS.
266
+
267
+ Args:
268
+ line: Source line containing the match
269
+ match_start: Character offset of match start (0-indexed)
270
+ match_end: Character offset of match end (exclusive)
271
+
272
+ Returns:
273
+ True if classification is AMBIGUOUS, False otherwise
274
+ """
275
+ return (
276
+ self.classify(line, match_start, match_end).classification
277
+ == Classification.AMBIGUOUS
278
+ )
279
+
280
+ def classify_batch(
281
+ self, candidates: list[Tuple[str, int, int]]
282
+ ) -> list[ClassificationResult]:
283
+ """
284
+ Classify multiple candidates in batch.
285
+
286
+ Args:
287
+ candidates: List of (line, match_start, match_end) tuples
288
+
289
+ Returns:
290
+ List of ClassificationResult objects in same order as input
291
+ """
292
+ return [self.classify(line, start, end) for line, start, end in candidates]
293
+
294
+
295
+ # Convenience function for one-shot classification
296
+ def classify_match(line: str, match_start: int, match_end: int) -> ClassificationResult:
297
+ """
298
+ One-shot convenience function: classify a single match.
299
+
300
+ Args:
301
+ line: Source line containing the match
302
+ match_start: Character offset of match start (0-indexed)
303
+ match_end: Character offset of match end (exclusive)
304
+
305
+ Returns:
306
+ ClassificationResult
307
+
308
+ Example:
309
+ result = classify_match("api_key = 'sk_live_abc123'", 10, 27)
310
+ assert result.classification == Classification.LIKELY_SECRET
311
+ """
312
+ classifier = ContextClassifier()
313
+ return classifier.classify(line, match_start, match_end)