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,174 @@
|
|
|
1
|
+
"""Assemble one evidence record per image from every source that can speak.
|
|
2
|
+
|
|
3
|
+
Three sources can say something about how an image is built, and they are
|
|
4
|
+
not equal:
|
|
5
|
+
|
|
6
|
+
* the **registry** serves the OCI config of a resolved digest -- a
|
|
7
|
+
measurement of the published artifact;
|
|
8
|
+
* the **scanner** reports the packages it found -- also a measurement, and
|
|
9
|
+
the only one that can see inside the filesystem;
|
|
10
|
+
* the **catalogue** publishes the vendor's build definition -- a claim about
|
|
11
|
+
intent.
|
|
12
|
+
|
|
13
|
+
They are merged in that order, and the precedence is absolute: a measurement
|
|
14
|
+
is never overwritten by a claim. Where a claim contradicts a measurement,
|
|
15
|
+
the contradiction is *recorded* rather than resolved, because a vendor
|
|
16
|
+
saying an image runs unprivileged while its config says root is a finding in
|
|
17
|
+
its own right -- arguably the most useful thing this whole comparison
|
|
18
|
+
produces.
|
|
19
|
+
|
|
20
|
+
The asymmetry from `DeclaredImageMetadata` carries through here: presence of
|
|
21
|
+
a shell package proves a shell, absence proves nothing. Every fact this
|
|
22
|
+
service cannot establish stays UNKNOWN, and UNKNOWN earns no credit in any
|
|
23
|
+
score downstream.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
from loguru import logger
|
|
31
|
+
|
|
32
|
+
from dockerls.domain.entities.declared_metadata import (
|
|
33
|
+
DEBUG_TOOL_PACKAGES,
|
|
34
|
+
PACKAGE_MANAGER_PACKAGES,
|
|
35
|
+
SHELL_PACKAGES,
|
|
36
|
+
)
|
|
37
|
+
from dockerls.domain.entities.image_facts import EvidenceSource, HardeningFacts
|
|
38
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from dockerls.domain.entities.image import DockerImage
|
|
42
|
+
from dockerls.domain.entities.scan_result import ScanResult
|
|
43
|
+
from dockerls.integrations.registry.inspector import RegistryInspector
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HardeningAnalyzer:
|
|
47
|
+
"""Produces the digest and merged facts for one candidate."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, inspector: RegistryInspector | None = None):
|
|
50
|
+
self._inspector = inspector
|
|
51
|
+
|
|
52
|
+
async def resolve_digest(self, image: DockerImage) -> str:
|
|
53
|
+
"""The image's manifest digest, or "" when it cannot be resolved.
|
|
54
|
+
|
|
55
|
+
Exposed separately from `analyze` because deduplication needs the
|
|
56
|
+
digest for *every* candidate, while the full evidence gathering is
|
|
57
|
+
only worth doing for the finalists.
|
|
58
|
+
"""
|
|
59
|
+
if self._inspector is None:
|
|
60
|
+
return ""
|
|
61
|
+
try:
|
|
62
|
+
return await self._inspector.resolve_digest(image)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.warning(f"Could not resolve a digest for {image.full_reference}: {e}")
|
|
65
|
+
return ""
|
|
66
|
+
|
|
67
|
+
async def close(self) -> None:
|
|
68
|
+
"""Release the registry connection pools this analyzer opened."""
|
|
69
|
+
if self._inspector is not None:
|
|
70
|
+
await self._inspector.close()
|
|
71
|
+
|
|
72
|
+
async def analyze(
|
|
73
|
+
self, image: DockerImage, scan: ScanResult | None
|
|
74
|
+
) -> tuple[str, HardeningFacts]:
|
|
75
|
+
"""Return (resolved digest, merged facts).
|
|
76
|
+
|
|
77
|
+
The digest is "" when the registry could not be reached; the caller
|
|
78
|
+
keeps whatever digest it already had rather than clearing it.
|
|
79
|
+
"""
|
|
80
|
+
digest, facts = "", HardeningFacts()
|
|
81
|
+
if self._inspector is not None:
|
|
82
|
+
try:
|
|
83
|
+
digest, facts = await self._inspector.inspect(image)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
# Inspection is enrichment. A registry that will not answer
|
|
86
|
+
# must cost the candidate its *facts*, never its analysis.
|
|
87
|
+
logger.warning(f"Could not inspect {image.full_reference}: {e}")
|
|
88
|
+
|
|
89
|
+
if scan is not None:
|
|
90
|
+
facts = _merge_scanner_evidence(facts, scan)
|
|
91
|
+
if image.declared is not None:
|
|
92
|
+
facts = _merge_declared(facts, image)
|
|
93
|
+
return digest, facts
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _merge_scanner_evidence(facts: HardeningFacts, scan: ScanResult) -> HardeningFacts:
|
|
97
|
+
"""Fold in what the scanner saw inside the filesystem.
|
|
98
|
+
|
|
99
|
+
The scanner reports packages that carry vulnerabilities, not the full
|
|
100
|
+
inventory, so this can only ever establish *presence*: seeing `bash`
|
|
101
|
+
among the findings proves a shell is installed, while not seeing it
|
|
102
|
+
proves nothing at all and leaves the fact UNKNOWN. The package count is
|
|
103
|
+
deliberately not derived from this -- the number of vulnerable packages
|
|
104
|
+
is not the number of packages, and reporting it as one would be a
|
|
105
|
+
fabricated measurement.
|
|
106
|
+
"""
|
|
107
|
+
names = {v.package_name.strip().lower() for v in scan.vulnerabilities if v.package_name}
|
|
108
|
+
if not names:
|
|
109
|
+
return facts
|
|
110
|
+
|
|
111
|
+
updates: dict[str, object] = {}
|
|
112
|
+
evidence = dict(facts.evidence)
|
|
113
|
+
for fact, known in (
|
|
114
|
+
("has_shell", SHELL_PACKAGES),
|
|
115
|
+
("has_package_manager", PACKAGE_MANAGER_PACKAGES),
|
|
116
|
+
("has_debug_tools", DEBUG_TOOL_PACKAGES),
|
|
117
|
+
):
|
|
118
|
+
if names & known and not getattr(facts, fact).is_true:
|
|
119
|
+
updates[fact] = Tristate.TRUE
|
|
120
|
+
evidence[fact] = EvidenceSource.SCANNER
|
|
121
|
+
|
|
122
|
+
if not updates:
|
|
123
|
+
return facts
|
|
124
|
+
updates["evidence"] = evidence
|
|
125
|
+
return facts.model_copy(update=updates)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _merge_declared(facts: HardeningFacts, image: DockerImage) -> HardeningFacts:
|
|
129
|
+
"""Fill remaining gaps from the catalogue, and record contradictions.
|
|
130
|
+
|
|
131
|
+
Only facts still UNKNOWN are filled. Anything the registry or the
|
|
132
|
+
scanner determined stands, and a declaration that disagrees with it is
|
|
133
|
+
appended to `conflicts` instead of being applied.
|
|
134
|
+
"""
|
|
135
|
+
declared = image.declared
|
|
136
|
+
if declared is None:
|
|
137
|
+
return facts
|
|
138
|
+
|
|
139
|
+
updates: dict[str, object] = {}
|
|
140
|
+
evidence = dict(facts.evidence)
|
|
141
|
+
conflicts = list(facts.conflicts)
|
|
142
|
+
|
|
143
|
+
claims = (
|
|
144
|
+
("runs_as_non_root", declared.declared_non_root, "runs as a non-root account"),
|
|
145
|
+
("has_shell", declared.declared_has_shell, "ships a shell"),
|
|
146
|
+
("has_package_manager", declared.declared_has_package_manager, "ships a package manager"),
|
|
147
|
+
("has_debug_tools", declared.declared_has_debug_tools, "ships debug tooling"),
|
|
148
|
+
)
|
|
149
|
+
for fact, claim, description in claims:
|
|
150
|
+
if not claim.is_known:
|
|
151
|
+
continue
|
|
152
|
+
current: Tristate = getattr(facts, fact)
|
|
153
|
+
if not current.is_known:
|
|
154
|
+
updates[fact] = claim
|
|
155
|
+
evidence[fact] = EvidenceSource.CATALOG
|
|
156
|
+
elif current is not claim:
|
|
157
|
+
conflicts.append(
|
|
158
|
+
f"{declared.catalog or 'the catalogue'} declares this image "
|
|
159
|
+
f"{'' if claim.is_true else 'does not '}{description}, but the "
|
|
160
|
+
f"{facts.source_of(fact).value} evidence says otherwise"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if facts.package_count is None and declared.declared_package_count is not None:
|
|
164
|
+
updates["package_count"] = declared.declared_package_count
|
|
165
|
+
evidence["package_count"] = EvidenceSource.CATALOG
|
|
166
|
+
|
|
167
|
+
if not facts.os_family and declared.os_id:
|
|
168
|
+
updates["os_family"] = declared.os_id
|
|
169
|
+
|
|
170
|
+
if not updates and conflicts == facts.conflicts:
|
|
171
|
+
return facts
|
|
172
|
+
updates["evidence"] = evidence
|
|
173
|
+
updates["conflicts"] = conflicts
|
|
174
|
+
return facts.model_copy(update=updates)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""What changing base images actually costs, stated before it is recommended.
|
|
2
|
+
|
|
3
|
+
A tool that ranks images and stops there is only half-useful, because the
|
|
4
|
+
expensive part of acting on the ranking is not choosing -- it is finding out,
|
|
5
|
+
three days later, that the native module your application depends on was
|
|
6
|
+
compiled against glibc and the "safer" image ships musl. The security
|
|
7
|
+
argument for a migration is easy; the compatibility argument is the one that
|
|
8
|
+
decides whether the migration happens.
|
|
9
|
+
|
|
10
|
+
So every suggestion this codebase makes travels with its costs. The rules
|
|
11
|
+
below are deliberately conservative in one direction: a trade-off is raised
|
|
12
|
+
whenever the evidence *permits* a problem, and compatibility is never
|
|
13
|
+
asserted. There is no analysis here -- and there could not be one -- that can
|
|
14
|
+
tell you your application still runs. The checklist exists because that
|
|
15
|
+
question can only be answered by running it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, Field
|
|
23
|
+
|
|
24
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from dockerls.application.dto.analysis import ImageAnalysis
|
|
28
|
+
|
|
29
|
+
#: C library each distribution family ships. The musl/glibc split is the
|
|
30
|
+
#: single most common cause of a base-image migration failing: prebuilt
|
|
31
|
+
#: native extensions (Node's node-gyp output, Python wheels, Go cgo builds)
|
|
32
|
+
#: are linked against one and will not load under the other.
|
|
33
|
+
_LIBC_BY_FAMILY = {
|
|
34
|
+
"alpine": "musl",
|
|
35
|
+
"debian": "glibc",
|
|
36
|
+
"ubuntu": "glibc",
|
|
37
|
+
"wolfi": "glibc",
|
|
38
|
+
"chainguard": "glibc",
|
|
39
|
+
"redhat": "glibc",
|
|
40
|
+
"rhel": "glibc",
|
|
41
|
+
"centos": "glibc",
|
|
42
|
+
"rocky": "glibc",
|
|
43
|
+
"almalinux": "glibc",
|
|
44
|
+
"amazon": "glibc",
|
|
45
|
+
"fedora": "glibc",
|
|
46
|
+
"opensuse": "glibc",
|
|
47
|
+
"sles": "glibc",
|
|
48
|
+
"photon": "glibc",
|
|
49
|
+
"oracle": "glibc",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#: Package manager each family uses, so a Dockerfile that runs `apk add`
|
|
53
|
+
#: can be told it will need rewriting rather than discovering it at build
|
|
54
|
+
#: time.
|
|
55
|
+
_PACKAGE_MANAGER_BY_FAMILY = {
|
|
56
|
+
"alpine": "apk",
|
|
57
|
+
"wolfi": "apk",
|
|
58
|
+
"chainguard": "apk",
|
|
59
|
+
"debian": "apt",
|
|
60
|
+
"ubuntu": "apt",
|
|
61
|
+
"redhat": "dnf/yum",
|
|
62
|
+
"rhel": "dnf/yum",
|
|
63
|
+
"centos": "dnf/yum",
|
|
64
|
+
"rocky": "dnf/yum",
|
|
65
|
+
"almalinux": "dnf/yum",
|
|
66
|
+
"amazon": "dnf/yum",
|
|
67
|
+
"fedora": "dnf",
|
|
68
|
+
"opensuse": "zypper",
|
|
69
|
+
"sles": "zypper",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class MigrationPlan(BaseModel):
|
|
74
|
+
"""The case for one migration, with its costs and the work it implies."""
|
|
75
|
+
|
|
76
|
+
from_reference: str
|
|
77
|
+
to_reference: str
|
|
78
|
+
#: Digest-pinned form of the target, when one was resolved. This is what
|
|
79
|
+
#: should go in a Dockerfile: a tag can move, a digest cannot.
|
|
80
|
+
to_pinned_reference: str = ""
|
|
81
|
+
#: Change in security score. Negative means the "alternative" is worse,
|
|
82
|
+
#: which is reported rather than hidden.
|
|
83
|
+
score_delta: float = 0.0
|
|
84
|
+
critical_delta: int = 0
|
|
85
|
+
high_delta: int = 0
|
|
86
|
+
improvements: list[str] = Field(default_factory=list)
|
|
87
|
+
trade_offs: list[str] = Field(default_factory=list)
|
|
88
|
+
checklist: list[str] = Field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def plan_migration(current: ImageAnalysis, target: ImageAnalysis) -> MigrationPlan:
|
|
92
|
+
"""Compare two *measured* images and describe the move between them."""
|
|
93
|
+
return MigrationPlan(
|
|
94
|
+
from_reference=current.image.full_reference,
|
|
95
|
+
to_reference=target.image.full_reference,
|
|
96
|
+
to_pinned_reference=target.image.pinned_reference,
|
|
97
|
+
score_delta=round(target.security_score - current.security_score, 1),
|
|
98
|
+
critical_delta=target.scan.critical_count - current.scan.critical_count,
|
|
99
|
+
high_delta=target.scan.high_count - current.scan.high_count,
|
|
100
|
+
improvements=_improvements(current, target),
|
|
101
|
+
trade_offs=_trade_offs(current, target),
|
|
102
|
+
checklist=_checklist(current, target),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _improvements(current: ImageAnalysis, target: ImageAnalysis) -> list[str]:
|
|
107
|
+
"""Gains, each one a difference between two measurements."""
|
|
108
|
+
gains: list[str] = []
|
|
109
|
+
|
|
110
|
+
for label, before, after in (
|
|
111
|
+
("CRITICAL", current.scan.critical_count, target.scan.critical_count),
|
|
112
|
+
("HIGH", current.scan.high_count, target.scan.high_count),
|
|
113
|
+
("MEDIUM", current.scan.medium_count, target.scan.medium_count),
|
|
114
|
+
):
|
|
115
|
+
if after < before:
|
|
116
|
+
gains.append(f"{label}: {before} -> {after}")
|
|
117
|
+
|
|
118
|
+
before_kev = sum(1 for v in current.scan.vulnerabilities if v.exploit_known)
|
|
119
|
+
after_kev = sum(1 for v in target.scan.vulnerabilities if v.exploit_known)
|
|
120
|
+
if after_kev < before_kev:
|
|
121
|
+
gains.append(f"known-exploited (CISA KEV) findings: {before_kev} -> {after_kev}")
|
|
122
|
+
|
|
123
|
+
if current.is_eol and not target.is_eol:
|
|
124
|
+
gains.append("target is still supported; the current image is end-of-life")
|
|
125
|
+
if target.is_lts and not current.is_lts:
|
|
126
|
+
gains.append("target is a long-term-support release")
|
|
127
|
+
|
|
128
|
+
if target.facts.runs_as_non_root.is_true and not current.facts.runs_as_non_root.is_true:
|
|
129
|
+
gains.append("target runs as a non-root account by default")
|
|
130
|
+
if (
|
|
131
|
+
target.attack_surface.reportable
|
|
132
|
+
and current.attack_surface.reportable
|
|
133
|
+
and target.attack_surface.score < current.attack_surface.score
|
|
134
|
+
):
|
|
135
|
+
gains.append(
|
|
136
|
+
f"attack surface: {current.attack_surface.score:.0f} -> "
|
|
137
|
+
f"{target.attack_surface.score:.0f} (lower is better)"
|
|
138
|
+
)
|
|
139
|
+
if (
|
|
140
|
+
target.hardening.reportable
|
|
141
|
+
and current.hardening.reportable
|
|
142
|
+
and target.hardening.score > current.hardening.score
|
|
143
|
+
):
|
|
144
|
+
gains.append(f"hardening: {current.hardening.score:.0f} -> {target.hardening.score:.0f}")
|
|
145
|
+
if target.image.digest_known:
|
|
146
|
+
gains.append("target can be pinned to an immutable digest")
|
|
147
|
+
return _unique(gains)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _trade_offs(current: ImageAnalysis, target: ImageAnalysis) -> list[str]:
|
|
151
|
+
"""Costs. Raised whenever the evidence permits a problem."""
|
|
152
|
+
costs: list[str] = []
|
|
153
|
+
|
|
154
|
+
# Regressions first: an "alternative" that is worse on a severity band
|
|
155
|
+
# must say so before anything else.
|
|
156
|
+
for label, before, after in (
|
|
157
|
+
("CRITICAL", current.scan.critical_count, target.scan.critical_count),
|
|
158
|
+
("HIGH", current.scan.high_count, target.scan.high_count),
|
|
159
|
+
):
|
|
160
|
+
if after > before:
|
|
161
|
+
costs.append(f"{label} findings increase: {before} -> {after}")
|
|
162
|
+
|
|
163
|
+
costs.extend(_libc_trade_off(current, target))
|
|
164
|
+
costs.extend(_package_manager_trade_off(current, target))
|
|
165
|
+
costs.extend(_shell_trade_off(target))
|
|
166
|
+
costs.extend(_architecture_trade_off(current, target))
|
|
167
|
+
|
|
168
|
+
if target.image.source != current.image.source:
|
|
169
|
+
costs.append(
|
|
170
|
+
f"different publisher: {current.image.source} -> {target.image.source}; "
|
|
171
|
+
"check licensing, support and pull authentication before adopting"
|
|
172
|
+
)
|
|
173
|
+
if target.confidence.value != "HIGH":
|
|
174
|
+
costs.extend(target.confidence_reasons)
|
|
175
|
+
costs.extend(target.facts.conflicts)
|
|
176
|
+
|
|
177
|
+
declared = target.image.declared
|
|
178
|
+
if declared is not None and declared.end_of_life:
|
|
179
|
+
costs.append(f"target release is declared end-of-life on {declared.end_of_life}")
|
|
180
|
+
return _unique(costs)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _libc_trade_off(current: ImageAnalysis, target: ImageAnalysis) -> list[str]:
|
|
184
|
+
"""The musl/glibc question, answered only when both sides are known."""
|
|
185
|
+
before = _libc_of(current)
|
|
186
|
+
after = _libc_of(target)
|
|
187
|
+
if not before or not after:
|
|
188
|
+
# One side's base distribution was not identified, so nothing can be
|
|
189
|
+
# said. Saying "compatible" here is the error this whole module is
|
|
190
|
+
# written to avoid.
|
|
191
|
+
return [
|
|
192
|
+
"the base distribution of one side could not be identified: "
|
|
193
|
+
"assume the C library may differ and test native dependencies"
|
|
194
|
+
]
|
|
195
|
+
if before == after:
|
|
196
|
+
return []
|
|
197
|
+
return [
|
|
198
|
+
f"C library changes ({before} -> {after}): prebuilt native modules, "
|
|
199
|
+
"wheels and cgo binaries linked against the old one will not load and "
|
|
200
|
+
"must be rebuilt"
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _package_manager_trade_off(current: ImageAnalysis, target: ImageAnalysis) -> list[str]:
|
|
205
|
+
before = _PACKAGE_MANAGER_BY_FAMILY.get(_family_of(current), "")
|
|
206
|
+
after = _PACKAGE_MANAGER_BY_FAMILY.get(_family_of(target), "")
|
|
207
|
+
if not before or not after or before == after:
|
|
208
|
+
return []
|
|
209
|
+
return [
|
|
210
|
+
f"package manager changes ({before} -> {after}): every install step in "
|
|
211
|
+
"your Dockerfile needs rewriting, and package names differ between them"
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _shell_trade_off(target: ImageAnalysis) -> list[str]:
|
|
216
|
+
"""A missing shell is a hardening win and an operational cost at once."""
|
|
217
|
+
facts = target.facts
|
|
218
|
+
if facts.has_shell is Tristate.FALSE:
|
|
219
|
+
return [
|
|
220
|
+
"target has no shell: `docker exec` debugging, shell-form RUN/CMD "
|
|
221
|
+
"and entrypoint scripts will not work"
|
|
222
|
+
]
|
|
223
|
+
if facts.has_package_manager is Tristate.FALSE:
|
|
224
|
+
return [
|
|
225
|
+
"target has no package manager: anything your build installs at "
|
|
226
|
+
"runtime must move into a builder stage"
|
|
227
|
+
]
|
|
228
|
+
return []
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _architecture_trade_off(current: ImageAnalysis, target: ImageAnalysis) -> list[str]:
|
|
232
|
+
"""Flag architectures the current image supports and the target may not."""
|
|
233
|
+
before = {a for a in current.image.available_architectures if a}
|
|
234
|
+
after = {a for a in target.image.available_architectures if a}
|
|
235
|
+
if not before or not after:
|
|
236
|
+
return []
|
|
237
|
+
lost = sorted(before - after)
|
|
238
|
+
if not lost:
|
|
239
|
+
return []
|
|
240
|
+
return [
|
|
241
|
+
f"target does not publish {', '.join(lost)}: check every platform your deployment targets"
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _checklist(current: ImageAnalysis, target: ImageAnalysis) -> list[str]:
|
|
246
|
+
"""Ordered steps to validate the move, specific where the facts allow.
|
|
247
|
+
|
|
248
|
+
Generic enough to be honest, specific enough to be worth following: the
|
|
249
|
+
libc and non-root steps only appear when the evidence says they apply.
|
|
250
|
+
"""
|
|
251
|
+
steps = [
|
|
252
|
+
f"rebuild your image against {target.image.pinned_reference}",
|
|
253
|
+
]
|
|
254
|
+
if _libc_of(current) and _libc_of(target) and _libc_of(current) != _libc_of(target):
|
|
255
|
+
steps.append(
|
|
256
|
+
f"rebuild every native dependency for {_libc_of(target)} "
|
|
257
|
+
"(clear prebuilt binaries and caches first)"
|
|
258
|
+
)
|
|
259
|
+
steps.append("run the unit test suite against the rebuilt image")
|
|
260
|
+
steps.append("run the integration test suite against the rebuilt image")
|
|
261
|
+
if target.facts.runs_as_non_root.is_true and not current.facts.runs_as_non_root.is_true:
|
|
262
|
+
steps.append(
|
|
263
|
+
f"fix filesystem ownership for the non-root account "
|
|
264
|
+
f"({target.facts.user or 'the image default'}): writes to paths owned "
|
|
265
|
+
"by root will now fail"
|
|
266
|
+
)
|
|
267
|
+
if target.facts.has_shell is Tristate.FALSE:
|
|
268
|
+
steps.append("replace shell-form CMD/ENTRYPOINT and entrypoint scripts with exec form")
|
|
269
|
+
steps.append("re-scan the resulting image (`dockerls analyze <your-image>`)")
|
|
270
|
+
steps.append("verify runtime behaviour under production-like load")
|
|
271
|
+
steps.append("deploy to a canary before rolling out")
|
|
272
|
+
return steps
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _family_of(analysis: ImageAnalysis) -> str:
|
|
276
|
+
"""Base distribution, preferring the scanner's answer over any other.
|
|
277
|
+
|
|
278
|
+
The scanner reads the package database inside the image, which is the
|
|
279
|
+
only one of the available signals that cannot be wrong about what the
|
|
280
|
+
image actually is.
|
|
281
|
+
"""
|
|
282
|
+
family = (analysis.scan.os_family or analysis.facts.os_family or "").strip().lower()
|
|
283
|
+
return family
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _libc_of(analysis: ImageAnalysis) -> str:
|
|
287
|
+
return _LIBC_BY_FAMILY.get(_family_of(analysis), "")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _unique(items: list[str]) -> list[str]:
|
|
291
|
+
seen: set[str] = set()
|
|
292
|
+
unique: list[str] = []
|
|
293
|
+
for item in items:
|
|
294
|
+
if item and item not in seen:
|
|
295
|
+
seen.add(item)
|
|
296
|
+
unique.append(item)
|
|
297
|
+
return unique
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class ScanObserver(Protocol):
|
|
11
|
+
"""Reports scan progress to whatever owns the terminal.
|
|
12
|
+
|
|
13
|
+
The use case must not print: it emits events, and the CLI decides how
|
|
14
|
+
(or whether) to render them. This is what lets the terminal show a
|
|
15
|
+
single clean progress line while every diagnostic goes to the log file.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def start(self, total: int) -> None: ...
|
|
19
|
+
|
|
20
|
+
def scanning(self, image_reference: str) -> None: ...
|
|
21
|
+
|
|
22
|
+
def finished(self, image_reference: str, ok: bool) -> None: ...
|
|
23
|
+
|
|
24
|
+
def phase(self, description: str) -> None: ...
|
|
25
|
+
|
|
26
|
+
def phase_result(self, title: str, facts: Sequence[tuple[str, str]]) -> None:
|
|
27
|
+
"""Report what a finished phase actually did.
|
|
28
|
+
|
|
29
|
+
`phase()` says what is happening; this says what came of it -- how
|
|
30
|
+
many tags were found, how many collapsed onto one digest, how many
|
|
31
|
+
answers came from cache. The pipeline knew all of it and reported
|
|
32
|
+
none of it, so a long run looked identical to a stalled one.
|
|
33
|
+
|
|
34
|
+
Facts are ordered pairs rather than a mapping so the renderer can
|
|
35
|
+
print them in a deliberate order without sorting them into one.
|
|
36
|
+
"""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NullObserver:
|
|
41
|
+
"""No-op observer used when nothing is watching (JSON output, tests)."""
|
|
42
|
+
|
|
43
|
+
def start(self, total: int) -> None:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def scanning(self, image_reference: str) -> None:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def finished(self, image_reference: str, ok: bool) -> None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def phase(self, description: str) -> None:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def phase_result(self, title: str, facts: Sequence[tuple[str, str]]) -> None:
|
|
56
|
+
return None
|