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,303 @@
|
|
|
1
|
+
"""Hardening score: how well-configured an image is, independently of its CVEs.
|
|
2
|
+
|
|
3
|
+
Two images with identical vulnerability counts are not equally safe. One may
|
|
4
|
+
run as root with a shell, a package manager and a compiler in it; the other
|
|
5
|
+
may run as an unprivileged account with none of that. Nothing in a CVE count
|
|
6
|
+
expresses the difference, which is why hardening is a separate dimension
|
|
7
|
+
here rather than a term folded into the security score.
|
|
8
|
+
|
|
9
|
+
**Scoring over determined facts only.** The obvious design -- fixed weights
|
|
10
|
+
summed into a fixed maximum -- fails on the data actually available. Most
|
|
11
|
+
facts about most images cannot be determined without unpacking a filesystem,
|
|
12
|
+
so a fixed denominator would give a genuinely excellent image a score in the
|
|
13
|
+
thirties purely because nobody could inspect it, and that number would be
|
|
14
|
+
read as a hardening verdict. Instead the denominator is the weight of the
|
|
15
|
+
facts that *were* determined, and `coverage` reports how much of the model
|
|
16
|
+
that represents. A score of 100 at 30% coverage says "everything we could
|
|
17
|
+
check was good, and we could check less than a third of it" -- which is the
|
|
18
|
+
truth, and is what `Confidence` and the renderers surface next to it.
|
|
19
|
+
|
|
20
|
+
**Hardening never masks vulnerabilities.** This score is deliberately not an
|
|
21
|
+
input to `SecurityScore`. It is reported beside it, and the verdict layer
|
|
22
|
+
enforces the rule that no hardening result can lift an image carrying an
|
|
23
|
+
unfixable CRITICAL into "production ready". A perfectly configured image
|
|
24
|
+
full of exploitable CVEs is a perfectly configured vulnerable image.
|
|
25
|
+
|
|
26
|
+
Every weight below is a constant, documented in place, and covered by tests
|
|
27
|
+
that pin the arithmetic. Nothing here is tuned per-image or per-vendor.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
33
|
+
|
|
34
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from dockerls.domain.entities.image_facts import HardeningFacts
|
|
38
|
+
|
|
39
|
+
#: Weight of each hardening property, and the direction that earns credit.
|
|
40
|
+
#: The privilege facts dominate because privilege is what turns a bug into a
|
|
41
|
+
#: breach: a shell in an image that runs as an unprivileged account with no
|
|
42
|
+
#: capabilities is a much smaller problem than root with no shell.
|
|
43
|
+
NON_ROOT_WEIGHT = 25.0
|
|
44
|
+
NO_SHELL_WEIGHT = 15.0
|
|
45
|
+
NO_PACKAGE_MANAGER_WEIGHT = 12.0
|
|
46
|
+
NO_DEBUG_TOOLS_WEIGHT = 8.0
|
|
47
|
+
NO_SETUID_WEIGHT = 10.0
|
|
48
|
+
NO_PRIVILEGED_PORTS_WEIGHT = 8.0
|
|
49
|
+
MINIMAL_PACKAGES_WEIGHT = 12.0
|
|
50
|
+
EXPLICIT_ENTRYPOINT_WEIGHT = 5.0
|
|
51
|
+
HEALTHCHECK_WEIGHT = 5.0
|
|
52
|
+
|
|
53
|
+
#: Total weight of the model, used to express coverage. Not a denominator
|
|
54
|
+
#: for the score itself -- see the module docstring.
|
|
55
|
+
TOTAL_WEIGHT = (
|
|
56
|
+
NON_ROOT_WEIGHT
|
|
57
|
+
+ NO_SHELL_WEIGHT
|
|
58
|
+
+ NO_PACKAGE_MANAGER_WEIGHT
|
|
59
|
+
+ NO_DEBUG_TOOLS_WEIGHT
|
|
60
|
+
+ NO_SETUID_WEIGHT
|
|
61
|
+
+ NO_PRIVILEGED_PORTS_WEIGHT
|
|
62
|
+
+ MINIMAL_PACKAGES_WEIGHT
|
|
63
|
+
+ EXPLICIT_ENTRYPOINT_WEIGHT
|
|
64
|
+
+ HEALTHCHECK_WEIGHT
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
#: A package set at or below this is treated as minimal. Chosen from the
|
|
68
|
+
#: catalogue data: hardened runtime definitions install 10-40 packages,
|
|
69
|
+
#: while a general-purpose base lands in the hundreds.
|
|
70
|
+
MINIMAL_PACKAGE_COUNT = 50
|
|
71
|
+
|
|
72
|
+
#: Below this share of the model, the score is not reported as a number.
|
|
73
|
+
#: Two determined facts cannot characterise an image's hardening, and a
|
|
74
|
+
#: confident-looking number computed from them would be worse than no
|
|
75
|
+
#: number at all.
|
|
76
|
+
MIN_REPORTABLE_COVERAGE = 0.25
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class HardeningFactor(NamedTuple):
|
|
80
|
+
"""One scored property, kept so the score can explain itself."""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
weight: float
|
|
84
|
+
#: Credit earned, 0.0 to `weight`.
|
|
85
|
+
earned: float
|
|
86
|
+
#: False when the underlying fact was not determined; such a factor
|
|
87
|
+
#: contributes to neither the numerator nor the denominator.
|
|
88
|
+
determined: bool
|
|
89
|
+
detail: str = ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class HardeningScore:
|
|
93
|
+
"""Deterministic 0-100 hardening rating over the facts that are known."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, facts: HardeningFacts):
|
|
96
|
+
self._facts = facts
|
|
97
|
+
self._factors = _score_factors(facts)
|
|
98
|
+
determined = [f for f in self._factors if f.determined]
|
|
99
|
+
self._available = sum(f.weight for f in determined)
|
|
100
|
+
self._earned = sum(f.earned for f in determined)
|
|
101
|
+
self._value = (
|
|
102
|
+
round(100.0 * self._earned / self._available, 1) if self._available > 0 else 0.0
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def value(self) -> float:
|
|
107
|
+
"""0-100 over the determined facts. Meaningless below `MIN_REPORTABLE_COVERAGE`."""
|
|
108
|
+
return self._value
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def coverage(self) -> float:
|
|
112
|
+
"""Share of the hardening model that could be determined, 0.0-1.0."""
|
|
113
|
+
return round(self._available / TOTAL_WEIGHT, 3)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def is_reportable(self) -> bool:
|
|
117
|
+
"""Whether enough was determined for the number to mean anything."""
|
|
118
|
+
return self.coverage >= MIN_REPORTABLE_COVERAGE
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def strengths(self) -> list[str]:
|
|
122
|
+
"""Determined properties that earned full credit, for the "why" text."""
|
|
123
|
+
return [f.detail or f.name for f in self._factors if f.determined and f.earned >= f.weight]
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def weaknesses(self) -> list[str]:
|
|
127
|
+
"""Determined properties that earned nothing -- real, named findings."""
|
|
128
|
+
return [f.detail or f.name for f in self._factors if f.determined and f.earned <= 0.0]
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def undetermined(self) -> list[str]:
|
|
132
|
+
"""Properties nothing could establish, stated rather than hidden."""
|
|
133
|
+
return [f.name for f in self._factors if not f.determined]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _score_factors(facts: HardeningFacts) -> list[HardeningFactor]:
|
|
137
|
+
"""Evaluate every factor, marking the ones nothing determined.
|
|
138
|
+
|
|
139
|
+
Written as one flat list rather than a chain of conditionals so the
|
|
140
|
+
model is readable end to end: each line is a property, its weight, and
|
|
141
|
+
the fact that decides it.
|
|
142
|
+
"""
|
|
143
|
+
return [
|
|
144
|
+
_from_tristate(
|
|
145
|
+
"non-root",
|
|
146
|
+
NON_ROOT_WEIGHT,
|
|
147
|
+
facts.runs_as_non_root,
|
|
148
|
+
good=f"runs as {facts.user or 'a non-root account'}",
|
|
149
|
+
bad="runs as root by default",
|
|
150
|
+
),
|
|
151
|
+
_from_tristate(
|
|
152
|
+
"no-shell",
|
|
153
|
+
NO_SHELL_WEIGHT,
|
|
154
|
+
_negate(facts.has_shell),
|
|
155
|
+
good="no shell present",
|
|
156
|
+
bad="ships an interactive shell",
|
|
157
|
+
),
|
|
158
|
+
_from_tristate(
|
|
159
|
+
"no-package-manager",
|
|
160
|
+
NO_PACKAGE_MANAGER_WEIGHT,
|
|
161
|
+
_negate(facts.has_package_manager),
|
|
162
|
+
good="no package manager present",
|
|
163
|
+
bad="ships a package manager",
|
|
164
|
+
),
|
|
165
|
+
_from_tristate(
|
|
166
|
+
"no-debug-tools",
|
|
167
|
+
NO_DEBUG_TOOLS_WEIGHT,
|
|
168
|
+
_negate(facts.has_debug_tools),
|
|
169
|
+
good="no compilers or network utilities",
|
|
170
|
+
bad="ships compilers or network utilities",
|
|
171
|
+
),
|
|
172
|
+
_from_tristate(
|
|
173
|
+
"no-setuid",
|
|
174
|
+
NO_SETUID_WEIGHT,
|
|
175
|
+
_negate(facts.has_setuid),
|
|
176
|
+
good="no SUID/SGID binaries",
|
|
177
|
+
bad="contains SUID/SGID binaries",
|
|
178
|
+
),
|
|
179
|
+
_privileged_ports(facts),
|
|
180
|
+
_minimal_packages(facts),
|
|
181
|
+
_entrypoint(facts),
|
|
182
|
+
_from_tristate(
|
|
183
|
+
"healthcheck",
|
|
184
|
+
HEALTHCHECK_WEIGHT,
|
|
185
|
+
facts.has_healthcheck,
|
|
186
|
+
good="declares a healthcheck",
|
|
187
|
+
bad="declares no healthcheck",
|
|
188
|
+
),
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _negate(state: Tristate) -> Tristate:
|
|
193
|
+
"""Invert a fact, preserving UNKNOWN.
|
|
194
|
+
|
|
195
|
+
`not has_shell` on a bool would turn "nobody looked" into "no shell",
|
|
196
|
+
which is the exact substitution this module exists to prevent.
|
|
197
|
+
"""
|
|
198
|
+
if state is Tristate.UNKNOWN:
|
|
199
|
+
return Tristate.UNKNOWN
|
|
200
|
+
return Tristate.FALSE if state.is_true else Tristate.TRUE
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _from_tristate(
|
|
204
|
+
name: str, weight: float, state: Tristate, *, good: str, bad: str
|
|
205
|
+
) -> HardeningFactor:
|
|
206
|
+
if not state.is_known:
|
|
207
|
+
return HardeningFactor(name, weight, 0.0, determined=False)
|
|
208
|
+
earned = weight if state.is_true else 0.0
|
|
209
|
+
return HardeningFactor(
|
|
210
|
+
name, weight, earned, determined=True, detail=good if state.is_true else bad
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _privileged_ports(facts: HardeningFacts) -> HardeningFactor:
|
|
215
|
+
"""Credit for declaring no port that implies elevated privileges.
|
|
216
|
+
|
|
217
|
+
Determined only when the OCI config was actually read: an image whose
|
|
218
|
+
config was never fetched has an empty port list because nothing looked,
|
|
219
|
+
not because it declares none.
|
|
220
|
+
"""
|
|
221
|
+
name = "no-privileged-ports"
|
|
222
|
+
if not facts.ports_known:
|
|
223
|
+
return HardeningFactor(name, NO_PRIVILEGED_PORTS_WEIGHT, 0.0, determined=False)
|
|
224
|
+
privileged = facts.privileged_ports
|
|
225
|
+
if privileged:
|
|
226
|
+
detail = f"exposes privileged port(s) {', '.join(str(p) for p in sorted(privileged))}"
|
|
227
|
+
return HardeningFactor(
|
|
228
|
+
name, NO_PRIVILEGED_PORTS_WEIGHT, 0.0, determined=True, detail=detail
|
|
229
|
+
)
|
|
230
|
+
return HardeningFactor(
|
|
231
|
+
name,
|
|
232
|
+
NO_PRIVILEGED_PORTS_WEIGHT,
|
|
233
|
+
NO_PRIVILEGED_PORTS_WEIGHT,
|
|
234
|
+
determined=True,
|
|
235
|
+
detail="exposes no privileged ports",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _minimal_packages(facts: HardeningFacts) -> HardeningFactor:
|
|
240
|
+
"""Credit for a small package set, scaled rather than a cliff edge.
|
|
241
|
+
|
|
242
|
+
A step function at exactly 50 packages would rank a 50-package image
|
|
243
|
+
far above a 51-package one, which is not a real difference. Credit
|
|
244
|
+
tapers from full at `MINIMAL_PACKAGE_COUNT` to zero at four times that.
|
|
245
|
+
"""
|
|
246
|
+
name = "minimal-packages"
|
|
247
|
+
count = facts.package_count
|
|
248
|
+
if count is None:
|
|
249
|
+
return HardeningFactor(name, MINIMAL_PACKAGES_WEIGHT, 0.0, determined=False)
|
|
250
|
+
ceiling = MINIMAL_PACKAGE_COUNT * 4
|
|
251
|
+
if count <= MINIMAL_PACKAGE_COUNT:
|
|
252
|
+
earned = MINIMAL_PACKAGES_WEIGHT
|
|
253
|
+
elif count >= ceiling:
|
|
254
|
+
earned = 0.0
|
|
255
|
+
else:
|
|
256
|
+
span = ceiling - MINIMAL_PACKAGE_COUNT
|
|
257
|
+
earned = MINIMAL_PACKAGES_WEIGHT * (ceiling - count) / span
|
|
258
|
+
return HardeningFactor(
|
|
259
|
+
name,
|
|
260
|
+
MINIMAL_PACKAGES_WEIGHT,
|
|
261
|
+
round(earned, 3),
|
|
262
|
+
determined=True,
|
|
263
|
+
detail=f"{count} packages",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _entrypoint(facts: HardeningFacts) -> HardeningFactor:
|
|
268
|
+
"""Credit for declaring an explicit entrypoint.
|
|
269
|
+
|
|
270
|
+
An image with a fixed entrypoint constrains what running it does; one
|
|
271
|
+
whose entrypoint is a shell does the opposite, and is scored as such.
|
|
272
|
+
"""
|
|
273
|
+
name = "explicit-entrypoint"
|
|
274
|
+
if not facts.config_verified:
|
|
275
|
+
return HardeningFactor(name, EXPLICIT_ENTRYPOINT_WEIGHT, 0.0, determined=False)
|
|
276
|
+
entry = facts.entrypoint or facts.cmd
|
|
277
|
+
if not entry:
|
|
278
|
+
return HardeningFactor(
|
|
279
|
+
name, EXPLICIT_ENTRYPOINT_WEIGHT, 0.0, determined=True, detail="declares no entrypoint"
|
|
280
|
+
)
|
|
281
|
+
if _is_shell_entrypoint(entry):
|
|
282
|
+
return HardeningFactor(
|
|
283
|
+
name,
|
|
284
|
+
EXPLICIT_ENTRYPOINT_WEIGHT,
|
|
285
|
+
0.0,
|
|
286
|
+
determined=True,
|
|
287
|
+
detail=f"entrypoint is a shell ({entry[0]})",
|
|
288
|
+
)
|
|
289
|
+
return HardeningFactor(
|
|
290
|
+
name,
|
|
291
|
+
EXPLICIT_ENTRYPOINT_WEIGHT,
|
|
292
|
+
EXPLICIT_ENTRYPOINT_WEIGHT,
|
|
293
|
+
determined=True,
|
|
294
|
+
detail=f"entrypoint {' '.join(entry[:2])}",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
_SHELL_BASENAMES = frozenset({"sh", "bash", "ash", "dash", "zsh", "ksh", "busybox"})
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _is_shell_entrypoint(entry: list[str]) -> bool:
|
|
302
|
+
head = entry[0].strip() if entry else ""
|
|
303
|
+
return head.rsplit("/", 1)[-1] in _SHELL_BASENAMES
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Where an image reference says it will make this process connect.
|
|
2
|
+
|
|
3
|
+
The SSRF policy already guards the registry inspector, but the inspector is
|
|
4
|
+
not the only thing a reference reaches the network with. `trivy image X` and
|
|
5
|
+
`grype X` **pull X themselves**, over a socket this process opened, from a
|
|
6
|
+
host named inside the reference. A reference of
|
|
7
|
+
`169.254.169.254/latest:v1` therefore aimed the scanner's own pull at the
|
|
8
|
+
cloud metadata endpoint while the guard watched a request that was never
|
|
9
|
+
made -- the guard was on one door of a building with two.
|
|
10
|
+
|
|
11
|
+
Extracting the host is the pure half of closing that, and it lives here for
|
|
12
|
+
the same reason `NetworkPolicy` does: the rule can then be tested against
|
|
13
|
+
every reference shape without a network, and the resolution stays in
|
|
14
|
+
infrastructure where the sockets are.
|
|
15
|
+
|
|
16
|
+
The rule is Docker's own: the first path component is a registry host only
|
|
17
|
+
when it carries a dot or a colon, or is exactly `localhost`. Everything else
|
|
18
|
+
is a namespace on Docker Hub. `localhost` is the case a naive
|
|
19
|
+
dot-or-colon test misses, and it is precisely the one an attacker wants.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
#: Registry hosts that mean Docker Hub. Contacting them is the default
|
|
25
|
+
#: behaviour of every reference, so they are not host-qualified for policy.
|
|
26
|
+
_DOCKER_HUB_HOSTS = frozenset({"docker.io", "index.docker.io", "registry-1.docker.io"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def registry_host_of(reference: str) -> str:
|
|
30
|
+
"""The registry host named in `reference`, or "" when it means Docker Hub.
|
|
31
|
+
|
|
32
|
+
Returns the host **as written**, port included, because that is what an
|
|
33
|
+
allowlist entry and a policy explanation have to match.
|
|
34
|
+
"""
|
|
35
|
+
name = reference.strip()
|
|
36
|
+
if not name:
|
|
37
|
+
return ""
|
|
38
|
+
# A digest or tag suffix never contains the registry, and dropping it
|
|
39
|
+
# first stops `node@sha256:...` from looking port-qualified.
|
|
40
|
+
name = name.split("@", 1)[0]
|
|
41
|
+
head = name.split("/", 1)[0]
|
|
42
|
+
if "/" not in name:
|
|
43
|
+
return ""
|
|
44
|
+
if not is_registry_host(head):
|
|
45
|
+
return ""
|
|
46
|
+
return "" if head.lower() in _DOCKER_HUB_HOSTS else head
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_registry_host(segment: str) -> bool:
|
|
50
|
+
"""Whether a leading path component names a registry rather than a namespace.
|
|
51
|
+
|
|
52
|
+
`localhost` is included explicitly: it carries neither a dot nor a port,
|
|
53
|
+
so a dot-or-colon test reads `localhost/evil` as the Docker Hub user
|
|
54
|
+
"localhost" and lets the pull through to a service on this machine.
|
|
55
|
+
"""
|
|
56
|
+
return "." in segment or ":" in segment or segment.lower() == "localhost"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def split_repository_and_tag(reference: str) -> tuple[str, str]:
|
|
60
|
+
"""Split an image reference into (repository, tag).
|
|
61
|
+
|
|
62
|
+
Unlike a plain `rsplit(":", 1)`, this never mistakes a `host:port`
|
|
63
|
+
registry prefix for a tag: only a colon in the *last* path segment
|
|
64
|
+
counts. A digest suffix (`@sha256:...`) is stripped first and never
|
|
65
|
+
treated as part of the tag.
|
|
66
|
+
|
|
67
|
+
Mora aqui, ao lado de `is_registry_host`, porque é a mesma regra sobre a
|
|
68
|
+
mesma string: o que conta como host. `application/` precisa dela para
|
|
69
|
+
não ler a porta do registry como versão na consulta de EOL, e a camada
|
|
70
|
+
de aplicação não pode importar da `cli/` -- `test_architecture` reprova,
|
|
71
|
+
e com razão.
|
|
72
|
+
"""
|
|
73
|
+
ref = reference.split("@", 1)[0]
|
|
74
|
+
parts = ref.split("/")
|
|
75
|
+
prefix = ""
|
|
76
|
+
if len(parts) > 1 and is_registry_host(parts[0]):
|
|
77
|
+
prefix = parts[0] + "/"
|
|
78
|
+
parts = parts[1:]
|
|
79
|
+
tail = "/".join(parts)
|
|
80
|
+
# Only the *last* path segment, as the docstring says. `rsplit` over the
|
|
81
|
+
# whole tail read the colon of a second host-shaped segment as a tag
|
|
82
|
+
# separator and answered a "tag" with a slash in it -- something no
|
|
83
|
+
# Docker tag can contain. `_is_moving_reference` then saw a non-empty,
|
|
84
|
+
# non-`latest` tag and reported the base as pinned: the same
|
|
85
|
+
# port-hides-an-unpinned-base shape as DF013, one segment deeper.
|
|
86
|
+
head, separator, last = tail.rpartition("/")
|
|
87
|
+
if ":" in last:
|
|
88
|
+
repo_tail, tag = last.rsplit(":", 1)
|
|
89
|
+
return prefix + head + separator + repo_tail, tag
|
|
90
|
+
return ref, ""
|