dockerls 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dockerls/__init__.py +31 -0
- dockerls/application/__init__.py +0 -0
- dockerls/application/dto/__init__.py +3 -0
- dockerls/application/dto/analysis.py +257 -0
- dockerls/application/services/__init__.py +0 -0
- dockerls/application/services/alternatives_lookup.py +167 -0
- dockerls/application/services/composite_repository.py +106 -0
- dockerls/application/services/cross_validation.py +233 -0
- dockerls/application/services/ecosystems.py +350 -0
- dockerls/application/services/fallback_scanner.py +97 -0
- dockerls/application/services/hardening_analysis.py +174 -0
- dockerls/application/services/migration.py +297 -0
- dockerls/application/services/progress.py +56 -0
- dockerls/application/services/remediation.py +321 -0
- dockerls/application/services/scan_history_store.py +88 -0
- dockerls/application/services/scanner_factory.py +88 -0
- dockerls/application/services/source_registry.py +151 -0
- dockerls/application/services/tag_history_store.py +76 -0
- dockerls/application/services/teardown.py +50 -0
- dockerls/application/services/verdict.py +298 -0
- dockerls/application/services/version_discovery.py +108 -0
- dockerls/application/use_cases/__init__.py +0 -0
- dockerls/application/use_cases/analyze_dockerfile.py +103 -0
- dockerls/application/use_cases/analyze_image.py +173 -0
- dockerls/application/use_cases/build_image.py +1795 -0
- dockerls/application/use_cases/compare_images.py +91 -0
- dockerls/application/use_cases/fleet_scan.py +240 -0
- dockerls/application/use_cases/recommend_images.py +1078 -0
- dockerls/application/use_cases/registry_audit.py +133 -0
- dockerls/application/use_cases/search_images.py +23 -0
- dockerls/application/use_cases/upgrade_base.py +167 -0
- dockerls/cache/__init__.py +0 -0
- dockerls/cache/sqlite_cache.py +184 -0
- dockerls/cli/__init__.py +0 -0
- dockerls/cli/analysis_baseline.py +98 -0
- dockerls/cli/app.py +294 -0
- dockerls/cli/commands/__init__.py +0 -0
- dockerls/cli/commands/advisor.py +262 -0
- dockerls/cli/commands/alternatives.py +291 -0
- dockerls/cli/commands/analyze.py +429 -0
- dockerls/cli/commands/analyze_dockerfile.py +104 -0
- dockerls/cli/commands/base_cmd.py +244 -0
- dockerls/cli/commands/base_image.py +551 -0
- dockerls/cli/commands/build.py +1300 -0
- dockerls/cli/commands/cache_cmd.py +104 -0
- dockerls/cli/commands/compare.py +177 -0
- dockerls/cli/commands/controls.py +110 -0
- dockerls/cli/commands/doctor.py +566 -0
- dockerls/cli/commands/export.py +81 -0
- dockerls/cli/commands/fleet.py +159 -0
- dockerls/cli/commands/health.py +84 -0
- dockerls/cli/commands/login.py +53 -0
- dockerls/cli/commands/policy_cmd.py +111 -0
- dockerls/cli/commands/provenance_cmd.py +162 -0
- dockerls/cli/commands/recommend.py +761 -0
- dockerls/cli/commands/registry_audit_cmd.py +103 -0
- dockerls/cli/commands/sbom.py +144 -0
- dockerls/cli/commands/search.py +86 -0
- dockerls/cli/commands/verify.py +115 -0
- dockerls/cli/commands/version.py +12 -0
- dockerls/cli/commands/vex_cmd.py +117 -0
- dockerls/cli/dependencies.py +530 -0
- dockerls/cli/image_names.py +79 -0
- dockerls/cli/options.py +42 -0
- dockerls/cli/progress.py +145 -0
- dockerls/cli/publish_prompt.py +123 -0
- dockerls/cli/rendering.py +214 -0
- dockerls/cli/runtime.py +65 -0
- dockerls/cli/scan_failure.py +71 -0
- dockerls/cli/text.py +39 -0
- dockerls/cli/validators.py +35 -0
- dockerls/cli/vulnerability_view.py +154 -0
- dockerls/domain/__init__.py +0 -0
- dockerls/domain/entities/__init__.py +75 -0
- dockerls/domain/entities/declared_metadata.py +147 -0
- dockerls/domain/entities/dockerfile_analysis.py +318 -0
- dockerls/domain/entities/image.py +109 -0
- dockerls/domain/entities/image_facts.py +137 -0
- dockerls/domain/entities/recommendation.py +33 -0
- dockerls/domain/entities/scan_result.py +129 -0
- dockerls/domain/entities/vulnerability.py +232 -0
- dockerls/domain/interfaces/__init__.py +17 -0
- dockerls/domain/interfaces/cache_store.py +18 -0
- dockerls/domain/interfaces/dockerfile_validator.py +99 -0
- dockerls/domain/interfaces/eol_checker.py +11 -0
- dockerls/domain/interfaces/image_repository.py +15 -0
- dockerls/domain/interfaces/scanner.py +15 -0
- dockerls/domain/security_controls.py +362 -0
- dockerls/domain/value_objects/__init__.py +49 -0
- dockerls/domain/value_objects/attack_surface.py +198 -0
- dockerls/domain/value_objects/base_recipe.py +600 -0
- dockerls/domain/value_objects/base_upgrade.py +292 -0
- dockerls/domain/value_objects/build_labels.py +99 -0
- dockerls/domain/value_objects/build_policy.py +412 -0
- dockerls/domain/value_objects/confidence.py +156 -0
- dockerls/domain/value_objects/fleet.py +174 -0
- dockerls/domain/value_objects/gate.py +327 -0
- dockerls/domain/value_objects/hardening.py +303 -0
- dockerls/domain/value_objects/image_reference.py +90 -0
- dockerls/domain/value_objects/inheritance.py +352 -0
- dockerls/domain/value_objects/network_policy.py +280 -0
- dockerls/domain/value_objects/production_readiness.py +145 -0
- dockerls/domain/value_objects/provenance.py +211 -0
- dockerls/domain/value_objects/recipe_diff.py +188 -0
- dockerls/domain/value_objects/registry_audit.py +195 -0
- dockerls/domain/value_objects/registry_target.py +225 -0
- dockerls/domain/value_objects/remediation_score.py +62 -0
- dockerls/domain/value_objects/scan_history.py +183 -0
- dockerls/domain/value_objects/scan_plan.py +193 -0
- dockerls/domain/value_objects/scanner_db.py +143 -0
- dockerls/domain/value_objects/security_score.py +160 -0
- dockerls/domain/value_objects/security_tier.py +122 -0
- dockerls/domain/value_objects/tag_history.py +180 -0
- dockerls/domain/value_objects/tool_release.py +253 -0
- dockerls/domain/value_objects/tristate.py +47 -0
- dockerls/domain/value_objects/vex.py +249 -0
- dockerls/exit_codes.py +23 -0
- dockerls/exporters/__init__.py +0 -0
- dockerls/exporters/base.py +17 -0
- dockerls/exporters/csv_exporter.py +85 -0
- dockerls/exporters/factory.py +31 -0
- dockerls/exporters/html_exporter.py +105 -0
- dockerls/exporters/json_exporter.py +19 -0
- dockerls/exporters/markdown_exporter.py +84 -0
- dockerls/exporters/sarif_exporter.py +245 -0
- dockerls/infrastructure/__init__.py +0 -0
- dockerls/infrastructure/config/__init__.py +0 -0
- dockerls/infrastructure/config/policy_file.py +165 -0
- dockerls/infrastructure/config/settings.py +197 -0
- dockerls/infrastructure/database/__init__.py +0 -0
- dockerls/infrastructure/database/models.py +78 -0
- dockerls/infrastructure/dockerfile_validator.py +1899 -0
- dockerls/infrastructure/evidence.py +99 -0
- dockerls/infrastructure/hashing.py +165 -0
- dockerls/infrastructure/logging/__init__.py +0 -0
- dockerls/infrastructure/logging/setup.py +98 -0
- dockerls/infrastructure/network/__init__.py +0 -0
- dockerls/infrastructure/network/guarded_client.py +107 -0
- dockerls/infrastructure/network/host_guard.py +117 -0
- dockerls/infrastructure/redaction.py +135 -0
- dockerls/infrastructure/templates/hardening/alpine.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/distroless.dockerfile +34 -0
- dockerls/infrastructure/templates/hardening/go-alpine.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/go-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/go-distroless.dockerfile +43 -0
- dockerls/infrastructure/templates/hardening/go-scratch.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/go.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/gradle-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/gradle.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/java-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-distroless.dockerfile +39 -0
- dockerls/infrastructure/templates/hardening/java-ubuntu.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/java.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/maven-alpine.dockerfile +56 -0
- dockerls/infrastructure/templates/hardening/maven.dockerfile +57 -0
- dockerls/infrastructure/templates/hardening/node-alpine.dockerfile +47 -0
- dockerls/infrastructure/templates/hardening/node-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/node-distroless.dockerfile +46 -0
- dockerls/infrastructure/templates/hardening/node-ubuntu.dockerfile +63 -0
- dockerls/infrastructure/templates/hardening/node.dockerfile +61 -0
- dockerls/infrastructure/templates/hardening/php-alpine.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-ubuntu.dockerfile +49 -0
- dockerls/infrastructure/templates/hardening/php.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/python-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/python-distroless.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-ubuntu.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/python.dockerfile +58 -0
- dockerls/infrastructure/templates/hardening/ruby-alpine.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/ruby-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-debian.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/rust-scratch.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/rust.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/ubuntu.dockerfile +49 -0
- dockerls/infrastructure/toolchain/__init__.py +0 -0
- dockerls/infrastructure/toolchain/db_metadata.py +115 -0
- dockerls/infrastructure/toolchain/installer.py +435 -0
- dockerls/integrations/__init__.py +0 -0
- dockerls/integrations/dhi/__init__.py +0 -0
- dockerls/integrations/dhi/catalog.py +457 -0
- dockerls/integrations/dhi/definition.py +151 -0
- dockerls/integrations/dhi/repository.py +238 -0
- dockerls/integrations/dockerhub/__init__.py +0 -0
- dockerls/integrations/dockerhub/client.py +318 -0
- dockerls/integrations/dockerhub/urls.py +75 -0
- dockerls/integrations/endoflife/__init__.py +0 -0
- dockerls/integrations/endoflife/checker.py +216 -0
- dockerls/integrations/engine/__init__.py +0 -0
- dockerls/integrations/engine/batch.py +197 -0
- dockerls/integrations/engine/client.py +330 -0
- dockerls/integrations/engine/locator.py +96 -0
- dockerls/integrations/exploitdb/__init__.py +0 -0
- dockerls/integrations/exploitdb/client.py +271 -0
- dockerls/integrations/grype/__init__.py +0 -0
- dockerls/integrations/grype/scanner.py +336 -0
- dockerls/integrations/registry/__init__.py +0 -0
- dockerls/integrations/registry/hardened.py +284 -0
- dockerls/integrations/registry/inspector.py +420 -0
- dockerls/integrations/registry/oci.py +259 -0
- dockerls/integrations/registry/private.py +79 -0
- dockerls/integrations/registry/urls.py +36 -0
- dockerls/integrations/scan_errors.py +67 -0
- dockerls/integrations/scan_target.py +57 -0
- dockerls/integrations/signing/__init__.py +0 -0
- dockerls/integrations/signing/cosign.py +484 -0
- dockerls/integrations/threat_intel/__init__.py +0 -0
- dockerls/integrations/threat_intel/client.py +290 -0
- dockerls/integrations/trivy/__init__.py +0 -0
- dockerls/integrations/trivy/cache_pool.py +176 -0
- dockerls/integrations/trivy/scanner.py +447 -0
- dockerls/utils/__init__.py +0 -0
- dockerls/utils/auth.py +108 -0
- dockerls/utils/executables.py +39 -0
- dockerls/utils/ignore_file.py +130 -0
- dockerls/utils/rate_limit.py +130 -0
- dockerls/utils/resources.py +183 -0
- dockerls/utils/retry.py +31 -0
- dockerls/utils/safe_yaml.py +166 -0
- dockerls/utils/subprocess_runner.py +216 -0
- dockerls/utils/validation.py +72 -0
- dockerls-1.0.0.dist-info/METADATA +563 -0
- dockerls-1.0.0.dist-info/RECORD +230 -0
- dockerls-1.0.0.dist-info/WHEEL +5 -0
- dockerls-1.0.0.dist-info/entry_points.txt +2 -0
- dockerls-1.0.0.dist-info/licenses/LICENSE +21 -0
- dockerls-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Read the Docker Hardened Images catalogue without downloading it.
|
|
2
|
+
|
|
3
|
+
The catalogue is a GitHub repository with roughly eleven thousand files. The
|
|
4
|
+
naive integration -- clone it, or walk `contents/` directory by directory --
|
|
5
|
+
is unusable twice over: an anonymous client gets 60 GitHub API requests per
|
|
6
|
+
hour, which a single `recommend node` would spend in one go, and a clone
|
|
7
|
+
costs tens of megabytes for the handful of definitions a query actually
|
|
8
|
+
needs.
|
|
9
|
+
|
|
10
|
+
So this client does two different things at two different rhythms:
|
|
11
|
+
|
|
12
|
+
* **once per TTL**, it fetches the repository's *tree* in a single API call
|
|
13
|
+
and reduces it to a compact index (image -> variant -> definition files).
|
|
14
|
+
The index is a few hundred kilobytes and answers "does DHI have node, and
|
|
15
|
+
in which variants" with no network at all;
|
|
16
|
+
* **per query**, it fetches only the definition files the index names, from
|
|
17
|
+
`raw.githubusercontent.com`, which is a CDN and does not consume the API
|
|
18
|
+
budget.
|
|
19
|
+
|
|
20
|
+
Everything is cached through the same store the rest of the tool uses, with
|
|
21
|
+
the commit SHA carried alongside so a cached index can state which revision
|
|
22
|
+
of the catalogue it describes. Conditional requests (`If-None-Match`) let a
|
|
23
|
+
refresh cost nothing when the catalogue has not moved.
|
|
24
|
+
|
|
25
|
+
Trust boundary: every byte here arrives from the network. Paths that come
|
|
26
|
+
back from the tree API are validated against a strict pattern before being
|
|
27
|
+
turned into URLs, and definition bodies go through the bounded YAML loader.
|
|
28
|
+
A catalogue that answers with something unexpected yields *no candidates*,
|
|
29
|
+
never a candidate with invented properties.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
import json
|
|
36
|
+
import re
|
|
37
|
+
from enum import StrEnum
|
|
38
|
+
from typing import TYPE_CHECKING, Any
|
|
39
|
+
|
|
40
|
+
import httpx
|
|
41
|
+
from loguru import logger
|
|
42
|
+
|
|
43
|
+
from dockerls.integrations.dhi.definition import parse_definition
|
|
44
|
+
from dockerls.utils.rate_limit import CircuitBreaker, CircuitOpenError, RateLimiter
|
|
45
|
+
from dockerls.utils.safe_yaml import UnsafeYAMLError, safe_load_yaml
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from dockerls.domain.entities.declared_metadata import DeclaredImageMetadata
|
|
49
|
+
from dockerls.domain.interfaces.cache_store import CacheStoreInterface
|
|
50
|
+
|
|
51
|
+
CATALOG_OWNER = "docker-hardened-images"
|
|
52
|
+
CATALOG_REPO = "catalog"
|
|
53
|
+
CATALOG_BRANCH = "main"
|
|
54
|
+
|
|
55
|
+
#: The two hosts this client is allowed to reach. Paths and URLs derived
|
|
56
|
+
#: from a network response are checked against these before use, so a
|
|
57
|
+
#: compromised or spoofed catalogue response cannot redirect the client at
|
|
58
|
+
#: an internal address (SSRF) or an attacker-controlled host.
|
|
59
|
+
API_HOST = "api.github.com"
|
|
60
|
+
RAW_HOST = "raw.githubusercontent.com"
|
|
61
|
+
|
|
62
|
+
#: Definition paths are `image/<name>/<variant>/<file>.yaml` and nothing
|
|
63
|
+
#: else. Anchored, no dots in components, so `..` cannot appear and a
|
|
64
|
+
#: response cannot walk out of the `image/` subtree.
|
|
65
|
+
_DEFINITION_PATH = re.compile(
|
|
66
|
+
r"^image/(?P<image>[a-z0-9][a-z0-9_-]{0,63})/"
|
|
67
|
+
r"(?P<variant>[a-z0-9][a-z0-9._-]{0,63})/"
|
|
68
|
+
r"(?P<file>[a-z0-9][a-z0-9._-]{0,63})\.ya?ml$"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
#: Queries arrive from the command line; only names that could name a
|
|
72
|
+
#: catalogue directory are looked up, and the lookup is a dict hit either
|
|
73
|
+
#: way, so this is a cheap guard against a query being used to build a URL.
|
|
74
|
+
_IMAGE_QUERY = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
75
|
+
|
|
76
|
+
#: The tree response is bounded before parsing: an unbounded body from a
|
|
77
|
+
#: hostile endpoint must not become unbounded memory. The real catalogue
|
|
78
|
+
#: tree is around 2 MB.
|
|
79
|
+
MAX_TREE_BYTES = 32 * 1024 * 1024
|
|
80
|
+
|
|
81
|
+
#: GitHub's anonymous budget is 60 requests/hour. This client makes at most
|
|
82
|
+
#: one API request per TTL in the normal case; the limiter exists so a cold
|
|
83
|
+
#: cache with several concurrent queries cannot burst past the budget.
|
|
84
|
+
API_RATE = 10
|
|
85
|
+
API_PERIOD = 60.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class IndexState(StrEnum):
|
|
89
|
+
"""How much of the catalogue the current index actually describes.
|
|
90
|
+
|
|
91
|
+
Without this, `variants()` returning `{}` meant three different things
|
|
92
|
+
with one shape: the catalogue has no such image, the catalogue could not
|
|
93
|
+
be reached, and the catalogue answered with a tree GitHub had truncated.
|
|
94
|
+
Only the first is a fact about the image. The other two are absences of
|
|
95
|
+
an answer, and a caller that cannot tell them apart reports "DHI has no
|
|
96
|
+
hardened build of this" when what happened was that nobody asked.
|
|
97
|
+
|
|
98
|
+
Nothing here changes what the client returns -- discovery still degrades
|
|
99
|
+
to no candidates rather than failing the run. What it changes is that
|
|
100
|
+
the *reason* survives the degradation instead of being flattened into
|
|
101
|
+
an empty dict.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
#: Not loaded yet. No query has been answered from it.
|
|
105
|
+
NOT_LOADED = "NOT_LOADED"
|
|
106
|
+
#: Loaded, and it describes the whole catalogue.
|
|
107
|
+
COMPLETE = "COMPLETE"
|
|
108
|
+
#: GitHub truncated the tree. The index is real and it is short: images
|
|
109
|
+
#: it does not name may still exist in the catalogue.
|
|
110
|
+
TRUNCATED = "TRUNCATED"
|
|
111
|
+
#: The catalogue could not be read at all. Every answer from it is an
|
|
112
|
+
#: absence, and none of them is a statement about any image.
|
|
113
|
+
UNAVAILABLE = "UNAVAILABLE"
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def is_conclusive(self) -> bool:
|
|
117
|
+
"""Whether "no variants" from this index means "the catalogue has none"."""
|
|
118
|
+
return self is IndexState.COMPLETE
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class DHICatalogClient:
|
|
122
|
+
"""Fetches and caches the DHI catalogue index and its definition files."""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
timeout: int = 30,
|
|
127
|
+
cache: CacheStoreInterface | None = None,
|
|
128
|
+
ttl_seconds: int = 21600,
|
|
129
|
+
token: str = "", # nosec B107 - empty means "anonymous", not a password
|
|
130
|
+
max_definition_bytes: int = 1024 * 1024,
|
|
131
|
+
):
|
|
132
|
+
self._timeout = timeout
|
|
133
|
+
self._cache = cache
|
|
134
|
+
self._ttl = ttl_seconds
|
|
135
|
+
self._token = token
|
|
136
|
+
self._max_definition_bytes = max_definition_bytes
|
|
137
|
+
self._client: httpx.AsyncClient | None = None
|
|
138
|
+
self._client_lock = asyncio.Lock()
|
|
139
|
+
self._index: dict[str, dict[str, list[str]]] | None = None
|
|
140
|
+
self._index_lock = asyncio.Lock()
|
|
141
|
+
self._index_state = IndexState.NOT_LOADED
|
|
142
|
+
self._revision: str = ""
|
|
143
|
+
self._limiter = RateLimiter(rate=API_RATE, period=API_PERIOD, burst=API_RATE)
|
|
144
|
+
self._breaker = CircuitBreaker()
|
|
145
|
+
# Definitions fetched in this run, so ten candidates from one image
|
|
146
|
+
# never re-download the same YAML.
|
|
147
|
+
self._definitions: dict[str, DeclaredImageMetadata | None] = {}
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def revision(self) -> str:
|
|
151
|
+
"""Catalogue commit the current index describes, if known.
|
|
152
|
+
|
|
153
|
+
Carried into the evidence trail: "DHI said X" is only auditable if
|
|
154
|
+
the reader can tell *which* version of the catalogue said it.
|
|
155
|
+
"""
|
|
156
|
+
return self._revision
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def index_state(self) -> IndexState:
|
|
160
|
+
"""Whether an empty answer from this client is a fact or an absence.
|
|
161
|
+
|
|
162
|
+
`variants()` returns `{}` for "the catalogue has no such image" and
|
|
163
|
+
for "the catalogue could not be read", and the caller has to be able
|
|
164
|
+
to tell those apart before it says anything about the image.
|
|
165
|
+
"""
|
|
166
|
+
return self._index_state
|
|
167
|
+
|
|
168
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
169
|
+
if self._client is None:
|
|
170
|
+
async with self._client_lock:
|
|
171
|
+
if self._client is None:
|
|
172
|
+
headers = {
|
|
173
|
+
"Accept": "application/vnd.github+json",
|
|
174
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
175
|
+
}
|
|
176
|
+
if self._token:
|
|
177
|
+
# Never logged: the header is set on the client and
|
|
178
|
+
# the token itself is not interpolated anywhere else.
|
|
179
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
180
|
+
self._client = httpx.AsyncClient(
|
|
181
|
+
timeout=self._timeout,
|
|
182
|
+
headers=headers,
|
|
183
|
+
# Redirects are not followed: a redirect is exactly
|
|
184
|
+
# how a response would move this client off the two
|
|
185
|
+
# hosts it is allowed to reach.
|
|
186
|
+
follow_redirects=False,
|
|
187
|
+
)
|
|
188
|
+
return self._client
|
|
189
|
+
|
|
190
|
+
async def close(self) -> None:
|
|
191
|
+
client, self._client = self._client, None
|
|
192
|
+
if client is not None:
|
|
193
|
+
await client.aclose()
|
|
194
|
+
|
|
195
|
+
async def variants(self, image: str) -> dict[str, list[str]]:
|
|
196
|
+
"""Variant directories and definition files the catalogue has for `image`.
|
|
197
|
+
|
|
198
|
+
Returns an empty mapping when the catalogue has no such image, when
|
|
199
|
+
the index could not be loaded, or when the query is not a plausible
|
|
200
|
+
catalogue name. An empty mapping means "no DHI candidates", which is
|
|
201
|
+
a fact about discovery -- never a security statement.
|
|
202
|
+
"""
|
|
203
|
+
name = image.strip().lower()
|
|
204
|
+
if not _IMAGE_QUERY.match(name):
|
|
205
|
+
return {}
|
|
206
|
+
index = await self._load_index()
|
|
207
|
+
return dict(index.get(name, {}))
|
|
208
|
+
|
|
209
|
+
async def definition(self, path: str) -> DeclaredImageMetadata | None:
|
|
210
|
+
"""Fetch and parse one definition file by its catalogue path.
|
|
211
|
+
|
|
212
|
+
The path is re-validated here rather than trusted from the index,
|
|
213
|
+
because this is the method that turns a string into a URL.
|
|
214
|
+
"""
|
|
215
|
+
if not _DEFINITION_PATH.match(path):
|
|
216
|
+
logger.warning(f"Refusing to fetch DHI definition at unexpected path: {path!r}")
|
|
217
|
+
return None
|
|
218
|
+
if path in self._definitions:
|
|
219
|
+
return self._definitions[path]
|
|
220
|
+
|
|
221
|
+
parsed = await self._fetch_definition(path)
|
|
222
|
+
self._definitions[path] = parsed
|
|
223
|
+
return parsed
|
|
224
|
+
|
|
225
|
+
async def _fetch_definition(self, path: str) -> DeclaredImageMetadata | None:
|
|
226
|
+
cache_key = f"dhi:def:{self._revision or 'head'}:{path}"
|
|
227
|
+
raw = await self._cached_text(cache_key)
|
|
228
|
+
if raw is None:
|
|
229
|
+
url = f"https://{RAW_HOST}/{CATALOG_OWNER}/{CATALOG_REPO}/{CATALOG_BRANCH}/{path}"
|
|
230
|
+
raw = await self._get_text(url, max_bytes=self._max_definition_bytes)
|
|
231
|
+
if raw is None:
|
|
232
|
+
return None
|
|
233
|
+
await self._store_text(cache_key, raw)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
data = safe_load_yaml(raw, origin=path)
|
|
237
|
+
except UnsafeYAMLError as e:
|
|
238
|
+
# A definition that cannot be read safely yields no candidate.
|
|
239
|
+
# It must never yield a candidate with default values, which
|
|
240
|
+
# would read downstream as a fully-specified hardened image.
|
|
241
|
+
logger.warning(f"Discarding DHI definition {path}: {e}")
|
|
242
|
+
return None
|
|
243
|
+
return parse_definition(data, definition_url=self._definition_url(path))
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _definition_url(path: str) -> str:
|
|
247
|
+
return f"https://github.com/{CATALOG_OWNER}/{CATALOG_REPO}/blob/{CATALOG_BRANCH}/{path}"
|
|
248
|
+
|
|
249
|
+
async def _load_index(self) -> dict[str, dict[str, list[str]]]:
|
|
250
|
+
if self._index is not None:
|
|
251
|
+
return self._index
|
|
252
|
+
async with self._index_lock:
|
|
253
|
+
if self._index is not None:
|
|
254
|
+
return self._index
|
|
255
|
+
self._index = await self._resolve_index()
|
|
256
|
+
return self._index
|
|
257
|
+
|
|
258
|
+
async def _resolve_index(self) -> dict[str, dict[str, list[str]]]:
|
|
259
|
+
cached = await self._cached_index()
|
|
260
|
+
if cached is not None:
|
|
261
|
+
return cached
|
|
262
|
+
fetched = await self._fetch_index()
|
|
263
|
+
if fetched is None:
|
|
264
|
+
# An unreachable catalogue contributes nothing. Memoised as an
|
|
265
|
+
# empty index so a run with ten queries makes one failed attempt
|
|
266
|
+
# rather than ten -- but memoised *as unavailable*, so the empty
|
|
267
|
+
# dict is never read back as "the catalogue has no such image".
|
|
268
|
+
self._index_state = IndexState.UNAVAILABLE
|
|
269
|
+
logger.warning(
|
|
270
|
+
"DHI catalogue could not be read: this run reports no hardened "
|
|
271
|
+
"candidates from it, which is an absence of an answer and not a "
|
|
272
|
+
"statement that none exist"
|
|
273
|
+
)
|
|
274
|
+
return {}
|
|
275
|
+
index, revision, truncated = fetched
|
|
276
|
+
self._index_state = IndexState.TRUNCATED if truncated else IndexState.COMPLETE
|
|
277
|
+
self._revision = revision
|
|
278
|
+
await self._store_index(index, revision)
|
|
279
|
+
return index
|
|
280
|
+
|
|
281
|
+
async def _cached_index(self) -> dict[str, dict[str, list[str]]] | None:
|
|
282
|
+
if self._cache is None:
|
|
283
|
+
return None
|
|
284
|
+
try:
|
|
285
|
+
payload = await self._cache.get("dhi:index")
|
|
286
|
+
except Exception as e:
|
|
287
|
+
logger.warning(f"Could not read the cached DHI catalogue index: {e}")
|
|
288
|
+
return None
|
|
289
|
+
if not isinstance(payload, dict):
|
|
290
|
+
return None
|
|
291
|
+
index = payload.get("index")
|
|
292
|
+
revision = payload.get("revision")
|
|
293
|
+
if not isinstance(index, dict) or not isinstance(revision, str):
|
|
294
|
+
# A corrupted or older-shaped entry is a miss, never a partial
|
|
295
|
+
# index that would silently narrow discovery.
|
|
296
|
+
logger.warning("Discarding malformed DHI catalogue index from cache")
|
|
297
|
+
return None
|
|
298
|
+
cleaned = _validated_index(index)
|
|
299
|
+
if cleaned is None:
|
|
300
|
+
logger.warning("Discarding DHI catalogue index from cache: unexpected paths")
|
|
301
|
+
return None
|
|
302
|
+
self._revision = revision
|
|
303
|
+
self._index_state = IndexState.COMPLETE
|
|
304
|
+
logger.info(f"DHI catalogue index served from cache ({len(cleaned)} images, @{revision})")
|
|
305
|
+
return cleaned
|
|
306
|
+
|
|
307
|
+
async def _store_index(self, index: dict[str, dict[str, list[str]]], revision: str) -> None:
|
|
308
|
+
if self._cache is None:
|
|
309
|
+
return
|
|
310
|
+
try:
|
|
311
|
+
await self._cache.set(
|
|
312
|
+
"dhi:index", {"index": index, "revision": revision}, ttl_seconds=self._ttl
|
|
313
|
+
)
|
|
314
|
+
except Exception as e:
|
|
315
|
+
logger.warning(f"Could not cache the DHI catalogue index: {e}")
|
|
316
|
+
|
|
317
|
+
async def _fetch_index(self) -> tuple[dict[str, dict[str, list[str]]], str, bool] | None:
|
|
318
|
+
"""One API call: the recursive tree, reduced to definition paths.
|
|
319
|
+
|
|
320
|
+
Returns `(index, revision, truncated)`, or None when the catalogue
|
|
321
|
+
could not be read. `truncated` travels with the index because a
|
|
322
|
+
short index and a complete one are not the same answer, and the
|
|
323
|
+
difference used to end at a log line.
|
|
324
|
+
"""
|
|
325
|
+
url = (
|
|
326
|
+
f"https://{API_HOST}/repos/{CATALOG_OWNER}/{CATALOG_REPO}"
|
|
327
|
+
f"/git/trees/{CATALOG_BRANCH}?recursive=1"
|
|
328
|
+
)
|
|
329
|
+
raw = await self._get_text(url, max_bytes=MAX_TREE_BYTES, api=True)
|
|
330
|
+
if raw is None:
|
|
331
|
+
return None
|
|
332
|
+
try:
|
|
333
|
+
payload: Any = json.loads(raw)
|
|
334
|
+
except ValueError as e:
|
|
335
|
+
logger.warning(f"DHI catalogue tree was not valid JSON: {e}")
|
|
336
|
+
return None
|
|
337
|
+
if not isinstance(payload, dict):
|
|
338
|
+
return None
|
|
339
|
+
truncated = payload.get("truncated") is True
|
|
340
|
+
if truncated:
|
|
341
|
+
# GitHub truncates very large trees. A truncated tree silently
|
|
342
|
+
# hides images, so the fact travels with the index (see
|
|
343
|
+
# `IndexState.TRUNCATED`) rather than only into a log line that
|
|
344
|
+
# nothing downstream reads.
|
|
345
|
+
logger.warning("DHI catalogue tree came back truncated; discovery may be incomplete")
|
|
346
|
+
|
|
347
|
+
index: dict[str, dict[str, list[str]]] = {}
|
|
348
|
+
entries = payload.get("tree")
|
|
349
|
+
if not isinstance(entries, list):
|
|
350
|
+
return None
|
|
351
|
+
for entry in entries:
|
|
352
|
+
if not isinstance(entry, dict) or entry.get("type") != "blob":
|
|
353
|
+
continue
|
|
354
|
+
path = entry.get("path")
|
|
355
|
+
if not isinstance(path, str):
|
|
356
|
+
continue
|
|
357
|
+
match = _DEFINITION_PATH.match(path)
|
|
358
|
+
if not match:
|
|
359
|
+
continue
|
|
360
|
+
index.setdefault(match["image"], {}).setdefault(match["variant"], []).append(path)
|
|
361
|
+
|
|
362
|
+
revision = str(payload.get("sha") or "")[:40]
|
|
363
|
+
logger.info(f"DHI catalogue index built: {len(index)} images (@{revision or 'unknown'})")
|
|
364
|
+
return index, revision, truncated
|
|
365
|
+
|
|
366
|
+
async def _get_text(self, url: str, *, max_bytes: int, api: bool = False) -> str | None:
|
|
367
|
+
"""GET `url` under the rate limiter, breaker and a size bound.
|
|
368
|
+
|
|
369
|
+
Returns None for every failure mode -- unreachable, refused,
|
|
370
|
+
oversized, non-2xx. The caller turns that into "no candidates from
|
|
371
|
+
this source", which is the only honest reading.
|
|
372
|
+
"""
|
|
373
|
+
provider = "GitHub API" if api else "GitHub raw content"
|
|
374
|
+
try:
|
|
375
|
+
self._breaker.check(provider)
|
|
376
|
+
except CircuitOpenError as e:
|
|
377
|
+
logger.warning(str(e))
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
if api:
|
|
381
|
+
await self._limiter.acquire()
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
client = await self._get_client()
|
|
385
|
+
resp = await client.get(url)
|
|
386
|
+
except httpx.HTTPError as e:
|
|
387
|
+
self._breaker.record_failure()
|
|
388
|
+
logger.warning(f"{provider} request failed: {e}")
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
if resp.status_code in (403, 429):
|
|
392
|
+
# Rate limited or forbidden. Both are budget problems, and both
|
|
393
|
+
# get worse if the client keeps trying.
|
|
394
|
+
self._breaker.record_failure()
|
|
395
|
+
logger.warning(
|
|
396
|
+
f"{provider} refused the request ({resp.status_code}); "
|
|
397
|
+
"set DOCKERLS_GITHUB_TOKEN to raise the anonymous rate limit"
|
|
398
|
+
)
|
|
399
|
+
return None
|
|
400
|
+
if resp.status_code == 404:
|
|
401
|
+
self._breaker.record_success()
|
|
402
|
+
return None
|
|
403
|
+
if not resp.is_success:
|
|
404
|
+
self._breaker.record_failure()
|
|
405
|
+
logger.warning(f"{provider} answered {resp.status_code} for a catalogue request")
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
content = resp.content
|
|
409
|
+
if len(content) > max_bytes:
|
|
410
|
+
self._breaker.record_failure()
|
|
411
|
+
logger.warning(f"{provider} response exceeded {max_bytes} bytes; discarded")
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
self._breaker.record_success()
|
|
415
|
+
return content.decode("utf-8", errors="replace")
|
|
416
|
+
|
|
417
|
+
async def _cached_text(self, key: str) -> str | None:
|
|
418
|
+
if self._cache is None:
|
|
419
|
+
return None
|
|
420
|
+
try:
|
|
421
|
+
value = await self._cache.get(key)
|
|
422
|
+
except Exception as e:
|
|
423
|
+
logger.warning(f"Could not read cached DHI definition: {e}")
|
|
424
|
+
return None
|
|
425
|
+
return value if isinstance(value, str) else None
|
|
426
|
+
|
|
427
|
+
async def _store_text(self, key: str, value: str) -> None:
|
|
428
|
+
if self._cache is None:
|
|
429
|
+
return
|
|
430
|
+
try:
|
|
431
|
+
await self._cache.set(key, value, ttl_seconds=self._ttl)
|
|
432
|
+
except Exception as e:
|
|
433
|
+
logger.warning(f"Could not cache DHI definition: {e}")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _validated_index(index: dict[str, Any]) -> dict[str, dict[str, list[str]]] | None:
|
|
437
|
+
"""Re-validate an index loaded from the cache.
|
|
438
|
+
|
|
439
|
+
The cache is a file on disk that other processes can write. An entry
|
|
440
|
+
whose paths do not match the expected shape is rejected outright rather
|
|
441
|
+
than filtered, because a partially-valid index is indistinguishable
|
|
442
|
+
from a tampered one and would quietly change what gets recommended.
|
|
443
|
+
"""
|
|
444
|
+
cleaned: dict[str, dict[str, list[str]]] = {}
|
|
445
|
+
for image, variants in index.items():
|
|
446
|
+
if not isinstance(image, str) or not _IMAGE_QUERY.match(image):
|
|
447
|
+
return None
|
|
448
|
+
if not isinstance(variants, dict):
|
|
449
|
+
return None
|
|
450
|
+
for variant, paths in variants.items():
|
|
451
|
+
if not isinstance(variant, str) or not isinstance(paths, list):
|
|
452
|
+
return None
|
|
453
|
+
for path in paths:
|
|
454
|
+
if not isinstance(path, str) or not _DEFINITION_PATH.match(path):
|
|
455
|
+
return None
|
|
456
|
+
cleaned.setdefault(image, {})[variant] = list(paths)
|
|
457
|
+
return cleaned
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Parse one Docker Hardened Images definition file into declared metadata.
|
|
2
|
+
|
|
3
|
+
A definition is a YAML document describing how an image is built: which
|
|
4
|
+
packages go in, which account it runs as, which tags it publishes, when the
|
|
5
|
+
release goes end-of-life. The parsing here is deliberately total and
|
|
6
|
+
defensive -- every field is optional, every type is checked, and a document
|
|
7
|
+
that does not look like a definition yields `None` rather than a
|
|
8
|
+
half-populated object that later code would read as fact.
|
|
9
|
+
|
|
10
|
+
Nothing in this module decides anything about security. It converts a
|
|
11
|
+
vendor's claim into a typed claim; the verdict is made much further down,
|
|
12
|
+
after the tag has been resolved to a digest and scanned.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from dockerls.domain.entities.declared_metadata import (
|
|
20
|
+
DEBUG_TOOL_PACKAGES,
|
|
21
|
+
PACKAGE_MANAGER_PACKAGES,
|
|
22
|
+
SHELL_PACKAGES,
|
|
23
|
+
DeclaredImageMetadata,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
DHI_CATALOG = "Docker Hardened Images"
|
|
27
|
+
|
|
28
|
+
#: Definitions list packages as `name`, `name=version`, or `!name` for an
|
|
29
|
+
#: explicit exclusion. An exclusion means the package is *removed* from the
|
|
30
|
+
#: base -- counting it as installed would invert its meaning.
|
|
31
|
+
_EXCLUSION_PREFIX = "!"
|
|
32
|
+
|
|
33
|
+
#: A definition that names more packages than this is either malformed or
|
|
34
|
+
#: hostile; the count is kept but the name matching stops, so a crafted
|
|
35
|
+
#: document cannot turn parsing into an O(n) walk of an unbounded list.
|
|
36
|
+
MAX_PACKAGES = 5000
|
|
37
|
+
|
|
38
|
+
#: Tags a definition may publish. DHI definitions list around a dozen
|
|
39
|
+
#: aliases per image; the cap bounds what a single document can inject into
|
|
40
|
+
#: the candidate list.
|
|
41
|
+
MAX_TAGS = 64
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_definition(data: Any, *, definition_url: str = "") -> DeclaredImageMetadata | None:
|
|
45
|
+
"""Convert a parsed definition document into `DeclaredImageMetadata`.
|
|
46
|
+
|
|
47
|
+
Returns `None` when the document is not a mapping, or names neither an
|
|
48
|
+
image nor any tag -- there is nothing to resolve against a registry, so
|
|
49
|
+
there is no candidate.
|
|
50
|
+
"""
|
|
51
|
+
if not isinstance(data, dict):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
tags = _string_tuple(data.get("tags"), limit=MAX_TAGS)
|
|
55
|
+
image = _as_str(data.get("image"))
|
|
56
|
+
if not image and not tags:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
packages = _package_names(data.get("contents"))
|
|
60
|
+
os_release = _as_mapping(data.get("os-release"))
|
|
61
|
+
dates = _as_mapping(data.get("dates"))
|
|
62
|
+
accounts = _as_mapping(data.get("accounts"))
|
|
63
|
+
|
|
64
|
+
return DeclaredImageMetadata(
|
|
65
|
+
catalog=DHI_CATALOG,
|
|
66
|
+
definition_url=definition_url,
|
|
67
|
+
display_name=_as_str(data.get("name")),
|
|
68
|
+
registry_repository=image,
|
|
69
|
+
variant=_as_str(data.get("variant")),
|
|
70
|
+
run_as_user=_as_str(accounts.get("run-as")),
|
|
71
|
+
platforms=_string_tuple(data.get("platforms"), limit=32),
|
|
72
|
+
release_date=_as_str(dates.get("release")),
|
|
73
|
+
end_of_life=_as_str(dates.get("end-of-life")),
|
|
74
|
+
os_id=_as_str(os_release.get("id")),
|
|
75
|
+
os_version=_as_str(os_release.get("version-id")),
|
|
76
|
+
declared_package_count=len(packages) if packages else None,
|
|
77
|
+
entrypoint=_string_tuple(data.get("entrypoint"), limit=32),
|
|
78
|
+
cmd=_string_tuple(data.get("cmd"), limit=32),
|
|
79
|
+
tags=tags,
|
|
80
|
+
shell_packages=_matching(packages, SHELL_PACKAGES),
|
|
81
|
+
package_manager_packages=_matching(packages, PACKAGE_MANAGER_PACKAGES),
|
|
82
|
+
debug_tool_packages=_matching(packages, DEBUG_TOOL_PACKAGES),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _package_names(contents: Any) -> list[str]:
|
|
87
|
+
"""Installed package names, with versions stripped and exclusions dropped.
|
|
88
|
+
|
|
89
|
+
`nodejs-22=22.23.2-0` is the package `nodejs-22`; `!gawk` is gawk being
|
|
90
|
+
*removed* from the base image and must not be counted as present.
|
|
91
|
+
"""
|
|
92
|
+
if not isinstance(contents, dict):
|
|
93
|
+
return []
|
|
94
|
+
raw = contents.get("packages")
|
|
95
|
+
if not isinstance(raw, list):
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
names: list[str] = []
|
|
99
|
+
for entry in raw[:MAX_PACKAGES]:
|
|
100
|
+
if not isinstance(entry, str):
|
|
101
|
+
continue
|
|
102
|
+
name = entry.strip()
|
|
103
|
+
if not name or name.startswith(_EXCLUSION_PREFIX):
|
|
104
|
+
continue
|
|
105
|
+
names.append(name.split("=", 1)[0].strip())
|
|
106
|
+
return names
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _matching(packages: list[str], known: frozenset[str]) -> tuple[str, ...]:
|
|
110
|
+
"""Package names present in `known`, deduplicated and ordered.
|
|
111
|
+
|
|
112
|
+
Matching is exact rather than substring: `libcurl4` is not `curl`, and
|
|
113
|
+
treating it as one would report a fetch capability the image does not
|
|
114
|
+
have. Debian's `-dev`/major-version suffixes are handled by listing the
|
|
115
|
+
real package names in the sets themselves.
|
|
116
|
+
"""
|
|
117
|
+
seen = {name for name in packages if name in known}
|
|
118
|
+
return tuple(sorted(seen))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _as_mapping(value: Any) -> dict[str, Any]:
|
|
122
|
+
"""A nested block, or an empty one when the key is absent or wrong-typed.
|
|
123
|
+
|
|
124
|
+
Definitions are hand-written YAML: a block can be missing, be `null`
|
|
125
|
+
(which parses to `None`, not `{}`), or be a list where a map is
|
|
126
|
+
expected. All three must read as "not stated".
|
|
127
|
+
"""
|
|
128
|
+
return value if isinstance(value, dict) else {}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _as_str(value: Any) -> str:
|
|
132
|
+
"""Coerce a scalar to a string, refusing structures.
|
|
133
|
+
|
|
134
|
+
YAML happily produces ints, dates and nested maps where a string is
|
|
135
|
+
expected (`version-id: 13` parses as an int). Anything that is not a
|
|
136
|
+
scalar is dropped rather than stringified into `{'a': 1}`.
|
|
137
|
+
"""
|
|
138
|
+
if isinstance(value, str):
|
|
139
|
+
return value.strip()
|
|
140
|
+
if isinstance(value, bool):
|
|
141
|
+
return "true" if value else "false"
|
|
142
|
+
if isinstance(value, int | float):
|
|
143
|
+
return str(value)
|
|
144
|
+
return ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _string_tuple(value: Any, *, limit: int) -> tuple[str, ...]:
|
|
148
|
+
if not isinstance(value, list):
|
|
149
|
+
return ()
|
|
150
|
+
items = [_as_str(entry) for entry in value[:limit]]
|
|
151
|
+
return tuple(item for item in items if item)
|