envshield 4.7.1__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.
- {envshield-4.7.1 → envshield-4.7.3}/PKG-INFO +49 -3
- {envshield-4.7.1 → envshield-4.7.3}/README.md +48 -2
- envshield-4.7.3/envshield/__init__.py +1 -0
- envshield-4.7.3/envshield/classifier/__init__.py +12 -0
- envshield-4.7.3/envshield/classifier/context_classifier.py +313 -0
- envshield-4.7.3/envshield/classifier/tokenizer.py +299 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/cli.py +82 -28
- {envshield-4.7.1 → envshield-4.7.3}/envshield/config/manager.py +108 -13
- envshield-4.7.3/envshield/core/dependency_diff.py +235 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/discovery.py +32 -13
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/doctor.py +7 -7
- envshield-4.7.3/envshield/core/enforcement.py +191 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/exceptions.py +15 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/explain.py +12 -11
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/importer.py +13 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/scanner.py +632 -9
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/schema_manager.py +48 -25
- {envshield-4.7.1 → envshield-4.7.3}/envshield/parsers/_docker_compose.py +88 -11
- envshield-4.7.3/envshield/parsers/factory.py +95 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield.egg-info/PKG-INFO +49 -3
- {envshield-4.7.1 → envshield-4.7.3}/envshield.egg-info/SOURCES.txt +4 -0
- {envshield-4.7.1 → envshield-4.7.3}/pyproject.toml +1 -1
- envshield-4.7.1/envshield/__init__.py +0 -1
- envshield-4.7.1/envshield/core/dependency_diff.py +0 -112
- envshield-4.7.1/envshield/parsers/factory.py +0 -52
- {envshield-4.7.1 → envshield-4.7.3}/LICENSE.md +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/__main__.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/config/__init__.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/__init__.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/contract_diff.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/dependency_snapshot.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/file_updater.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/generator.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/hooks_manager.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/inspector.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/schema_snapshot.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/schema_types.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/service_discovery.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/service_manager.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/core/setup_manager.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/parsers/__init__.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/parsers/_base.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/parsers/_deployment.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/parsers/_dotenv.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/parsers/_kubernetes.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/parsers/_python.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/state.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/utils/__init__.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/utils/git_utils.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield/utils/paths.py +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield.egg-info/dependency_links.txt +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield.egg-info/entry_points.txt +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield.egg-info/requires.txt +0 -0
- {envshield-4.7.1 → envshield-4.7.3}/envshield.egg-info/top_level.txt +0 -0
- {envshield-4.7.1 → 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.
|
|
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
|
|
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,7 +468,7 @@ 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
|
---
|
|
@@ -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
|
|
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,7 +409,7 @@ 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
|
---
|
|
@@ -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)
|