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,145 @@
|
|
|
1
|
+
"""The one place that decides whether an image may go to production.
|
|
2
|
+
|
|
3
|
+
Before this module the decision was `tier in (A, B) and not is_eol`, computed
|
|
4
|
+
inside `SecurityTier` from the score alone. That expression cannot see
|
|
5
|
+
whether the scan finished, whether two scanners agreed, or whether anything
|
|
6
|
+
was verified at all -- so a `PARTIAL` scan with no findings in the targets it
|
|
7
|
+
managed to read produced a high score, tier A, and `production_ready = True`,
|
|
8
|
+
while the very same analysis reported `confidence = UNVERIFIED`.
|
|
9
|
+
|
|
10
|
+
An analysis that says both things is worse than one that says neither. The
|
|
11
|
+
field a CI gate reads is this one, so it is the field that must be hardest to
|
|
12
|
+
fool.
|
|
13
|
+
|
|
14
|
+
The policy is deliberately a *list of blockers* rather than a boolean with a
|
|
15
|
+
reason attached: a reader who is told "not production ready" immediately asks
|
|
16
|
+
why, and an image can fail for several independent causes at once. Every
|
|
17
|
+
blocker names itself in the reader's terms, and the same list feeds the
|
|
18
|
+
terminal, `--format json` and every exporter.
|
|
19
|
+
|
|
20
|
+
**Absence of evidence blocks; it does not excuse.** An unknown EOL status
|
|
21
|
+
does not prove the image is supported, an unresolved digest does not prove
|
|
22
|
+
the tag is stable, and a missing second scanner does not prove the first one
|
|
23
|
+
was right. None of those *penalise* the score -- there is nothing to penalise
|
|
24
|
+
-- but none of them may be spent as if it were a positive finding either.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from enum import StrEnum
|
|
31
|
+
|
|
32
|
+
from dockerls.domain.value_objects.confidence import Confidence, confidence_rank
|
|
33
|
+
from dockerls.domain.value_objects.security_tier import PRODUCTION_READY_TIERS, Tier
|
|
34
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BlockingReason(StrEnum):
|
|
38
|
+
"""Why an image is not production ready. Stable codes, so a pipeline can
|
|
39
|
+
branch on them without parsing prose."""
|
|
40
|
+
|
|
41
|
+
NOT_MEASURED = "NOT_MEASURED"
|
|
42
|
+
LOW_CONFIDENCE = "LOW_CONFIDENCE"
|
|
43
|
+
END_OF_LIFE = "END_OF_LIFE"
|
|
44
|
+
CRITICAL_FINDINGS = "CRITICAL_FINDINGS"
|
|
45
|
+
HIGH_FINDINGS = "HIGH_FINDINGS"
|
|
46
|
+
UNFIXABLE_CRITICAL = "UNFIXABLE_CRITICAL"
|
|
47
|
+
SCANNER_DIVERGENCE = "SCANNER_DIVERGENCE"
|
|
48
|
+
TIER_TOO_LOW = "TIER_TOO_LOW"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
#: What each code means where a human reads it.
|
|
52
|
+
EXPLANATIONS: dict[BlockingReason, str] = {
|
|
53
|
+
BlockingReason.NOT_MEASURED: (
|
|
54
|
+
"the scan did not complete, so nothing about this image was measured"
|
|
55
|
+
),
|
|
56
|
+
BlockingReason.LOW_CONFIDENCE: (
|
|
57
|
+
"the evidence behind this result has a material problem (see confidence reasons)"
|
|
58
|
+
),
|
|
59
|
+
BlockingReason.END_OF_LIFE: "this release is end-of-life and will not receive security fixes",
|
|
60
|
+
BlockingReason.CRITICAL_FINDINGS: "CRITICAL vulnerabilities are present",
|
|
61
|
+
BlockingReason.HIGH_FINDINGS: "HIGH vulnerabilities are present",
|
|
62
|
+
BlockingReason.UNFIXABLE_CRITICAL: "a CRITICAL vulnerability has no fix available",
|
|
63
|
+
BlockingReason.SCANNER_DIVERGENCE: (
|
|
64
|
+
"two scanners disagreed materially and the disagreement is unresolved"
|
|
65
|
+
),
|
|
66
|
+
BlockingReason.TIER_TOO_LOW: "the security tier is below the production threshold",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#: Confidence below which no image is production ready, whatever its score.
|
|
70
|
+
#: MEDIUM is the floor rather than HIGH because HIGH requires a second
|
|
71
|
+
#: scanner, and refusing every single-scanner installation would make the
|
|
72
|
+
#: verdict a statement about the operator's toolchain instead of about the
|
|
73
|
+
#: image.
|
|
74
|
+
MINIMUM_CONFIDENCE = Confidence.MEDIUM
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class ReadinessInputs:
|
|
79
|
+
"""Everything the decision depends on, stated explicitly.
|
|
80
|
+
|
|
81
|
+
A frozen record rather than a reference to the analysis DTO: the domain
|
|
82
|
+
must not depend on the application layer, and listing the inputs is what
|
|
83
|
+
makes the rule auditable without reading its callers.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
tier: Tier
|
|
87
|
+
confidence: Confidence
|
|
88
|
+
scan_verified: bool
|
|
89
|
+
#: Tri-state on purpose: `UNKNOWN` does not block (nothing says the
|
|
90
|
+
#: release is dead) and does not excuse either -- it is surfaced, and it
|
|
91
|
+
#: keeps confidence from reaching the top.
|
|
92
|
+
eol: Tristate = Tristate.UNKNOWN
|
|
93
|
+
critical_count: int = 0
|
|
94
|
+
high_count: int = 0
|
|
95
|
+
unfixable_critical_count: int = 0
|
|
96
|
+
max_critical: int = 0
|
|
97
|
+
max_high: int = 0
|
|
98
|
+
has_material_divergence: bool = False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class ProductionReadiness:
|
|
103
|
+
"""Applies the policy and reports every reason it failed."""
|
|
104
|
+
|
|
105
|
+
inputs: ReadinessInputs
|
|
106
|
+
blockers: list[BlockingReason] = field(default_factory=list)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def evaluate(cls, inputs: ReadinessInputs) -> ProductionReadiness:
|
|
110
|
+
blockers: list[BlockingReason] = []
|
|
111
|
+
|
|
112
|
+
# Measurement first. Everything below is a statement about findings,
|
|
113
|
+
# and a statement about findings presupposes that somebody looked.
|
|
114
|
+
if not inputs.scan_verified:
|
|
115
|
+
blockers.append(BlockingReason.NOT_MEASURED)
|
|
116
|
+
if confidence_rank(inputs.confidence) < confidence_rank(MINIMUM_CONFIDENCE):
|
|
117
|
+
blockers.append(BlockingReason.LOW_CONFIDENCE)
|
|
118
|
+
|
|
119
|
+
if inputs.eol.is_true:
|
|
120
|
+
blockers.append(BlockingReason.END_OF_LIFE)
|
|
121
|
+
if inputs.critical_count > inputs.max_critical:
|
|
122
|
+
blockers.append(BlockingReason.CRITICAL_FINDINGS)
|
|
123
|
+
if inputs.high_count > inputs.max_high:
|
|
124
|
+
blockers.append(BlockingReason.HIGH_FINDINGS)
|
|
125
|
+
if inputs.unfixable_critical_count > 0:
|
|
126
|
+
blockers.append(BlockingReason.UNFIXABLE_CRITICAL)
|
|
127
|
+
if inputs.has_material_divergence:
|
|
128
|
+
blockers.append(BlockingReason.SCANNER_DIVERGENCE)
|
|
129
|
+
if inputs.tier not in PRODUCTION_READY_TIERS:
|
|
130
|
+
blockers.append(BlockingReason.TIER_TOO_LOW)
|
|
131
|
+
|
|
132
|
+
return cls(inputs=inputs, blockers=blockers)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def is_ready(self) -> bool:
|
|
136
|
+
return not self.blockers
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def reasons(self) -> list[str]:
|
|
140
|
+
"""The blockers in the reader's terms, in the order they were found."""
|
|
141
|
+
return [EXPLANATIONS[reason] for reason in self.blockers]
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def codes(self) -> list[str]:
|
|
145
|
+
return [reason.value for reason in self.blockers]
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""A cadeia entre o que entrou no build e o que saiu dele.
|
|
2
|
+
|
|
3
|
+
Um relatório de segurança afirma coisas sobre uma imagem. Sem procedência, a
|
|
4
|
+
afirmação não é verificável: dois builds do mesmo `--tag` produziam relatórios
|
|
5
|
+
indistinguíveis mesmo partindo de Dockerfiles diferentes, e nada ligava o
|
|
6
|
+
scan ao artefato que ele mediu. Numa cadeia de fornecimento, "nós escaneamos
|
|
7
|
+
essa imagem" sem digest é uma frase sobre nada.
|
|
8
|
+
|
|
9
|
+
O registro tem duas metades, e as duas existem por um motivo distinto:
|
|
10
|
+
|
|
11
|
+
* **antes do build** -- digest do Dockerfile, digest do contexto, digests das
|
|
12
|
+
imagens base declaradas nos `FROM`, e a revisão do repositório. Isto
|
|
13
|
+
responde "o que foi construído".
|
|
14
|
+
* **depois do build** -- id da imagem, digest do manifesto publicado, e a
|
|
15
|
+
identidade do scanner que a mediu. Isto responde "o que saiu, e quem
|
|
16
|
+
atestou".
|
|
17
|
+
|
|
18
|
+
A parte que faz disto controle e não decoração é a verificação entre as duas:
|
|
19
|
+
o Dockerfile e o contexto são digeridos de novo **depois** do build, e se
|
|
20
|
+
mudaram no meio do caminho o registro é marcado como quebrado. Um build cuja
|
|
21
|
+
entrada mudou enquanto ele acontecia não produziu a imagem que o relatório
|
|
22
|
+
descreve -- seja por edição concorrente, seja porque alguém quis exatamente
|
|
23
|
+
isso. É o mesmo princípio que governa o resto desta ferramenta: uma coisa que
|
|
24
|
+
não pôde ser verificada não é apresentada como verificada.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from enum import StrEnum
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ProvenanceStatus(StrEnum):
|
|
34
|
+
"""O que o registro pode afirmar sobre si mesmo."""
|
|
35
|
+
|
|
36
|
+
#: Entrada e saída digeridas, e a entrada não mudou durante o build.
|
|
37
|
+
VERIFIED = "VERIFIED"
|
|
38
|
+
#: O build aconteceu, mas algo não pôde ser digerido (contexto grande
|
|
39
|
+
#: demais, arquivo ilegível). Não é uma acusação -- é a ausência de prova.
|
|
40
|
+
INCOMPLETE = "INCOMPLETE"
|
|
41
|
+
#: O Dockerfile ou o contexto mudaram entre o início e o fim do build.
|
|
42
|
+
#: A imagem existe; o que ela não tem é uma entrada conhecida.
|
|
43
|
+
INPUT_CHANGED = "INPUT_CHANGED"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SourceDigests:
|
|
48
|
+
"""O que entrou. Medido antes do `docker build` começar."""
|
|
49
|
+
|
|
50
|
+
dockerfile: str = ""
|
|
51
|
+
context: str = ""
|
|
52
|
+
context_files: int = 0
|
|
53
|
+
#: `FROM` declarado -> digest resolvido, quando resolvível. Uma base sem
|
|
54
|
+
#: digest é uma tag móvel, e dizer isso é mais útil do que omitir.
|
|
55
|
+
base_images: dict[str, str] = field(default_factory=dict)
|
|
56
|
+
git_revision: str = ""
|
|
57
|
+
#: Repositório com alterações não commitadas no momento do build. O commit
|
|
58
|
+
#: sozinho mentiria sobre o que gerou a imagem.
|
|
59
|
+
git_dirty: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ArtifactDigests:
|
|
64
|
+
"""O que saiu. Medido depois do build, e depois do push quando há um."""
|
|
65
|
+
|
|
66
|
+
image_id: str = ""
|
|
67
|
+
#: Digest do manifesto no registry. Só existe após o push -- é o único
|
|
68
|
+
#: identificador que outra máquina consegue usar para puxar exatamente
|
|
69
|
+
#: esta imagem.
|
|
70
|
+
repo_digest: str = ""
|
|
71
|
+
published_reference: str = ""
|
|
72
|
+
scanner: str = ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class BuildProvenance:
|
|
77
|
+
"""Procedência completa de um build, pronta para ser arquivada."""
|
|
78
|
+
|
|
79
|
+
tag: str
|
|
80
|
+
source: SourceDigests = field(default_factory=SourceDigests)
|
|
81
|
+
artifact: ArtifactDigests = field(default_factory=ArtifactDigests)
|
|
82
|
+
#: Digests recalculados após o build. Iguais aos de `source` num build
|
|
83
|
+
#: íntegro.
|
|
84
|
+
source_after: SourceDigests = field(default_factory=SourceDigests)
|
|
85
|
+
started_at: str = ""
|
|
86
|
+
finished_at: str = ""
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def status(self) -> ProvenanceStatus:
|
|
90
|
+
if not self.source.dockerfile or not self.source_after.dockerfile:
|
|
91
|
+
return ProvenanceStatus.INCOMPLETE
|
|
92
|
+
if (
|
|
93
|
+
self.source.dockerfile != self.source_after.dockerfile
|
|
94
|
+
or self.source.context != self.source_after.context
|
|
95
|
+
):
|
|
96
|
+
return ProvenanceStatus.INPUT_CHANGED
|
|
97
|
+
if not self.artifact.image_id:
|
|
98
|
+
return ProvenanceStatus.INCOMPLETE
|
|
99
|
+
return ProvenanceStatus.VERIFIED
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def is_verified(self) -> bool:
|
|
103
|
+
return self.status is ProvenanceStatus.VERIFIED
|
|
104
|
+
|
|
105
|
+
def explain(self) -> str:
|
|
106
|
+
"""Uma frase que um humano usa para decidir se confia no registro."""
|
|
107
|
+
if self.status is ProvenanceStatus.INPUT_CHANGED:
|
|
108
|
+
changed = []
|
|
109
|
+
if self.source.dockerfile != self.source_after.dockerfile:
|
|
110
|
+
changed.append("the Dockerfile")
|
|
111
|
+
if self.source.context != self.source_after.context:
|
|
112
|
+
changed.append("the build context")
|
|
113
|
+
return (
|
|
114
|
+
f"{' and '.join(changed)} changed during the build: the image exists, "
|
|
115
|
+
"but does not correspond to the input measured at the start"
|
|
116
|
+
)
|
|
117
|
+
if self.status is ProvenanceStatus.INCOMPLETE:
|
|
118
|
+
return (
|
|
119
|
+
"provenance incomplete: part of the input or the output could not be "
|
|
120
|
+
"digested, so the chain does not close"
|
|
121
|
+
)
|
|
122
|
+
return "input and output digested, and the input did not change during the build"
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def from_dict(raw: object) -> BuildProvenance:
|
|
126
|
+
"""Reconstrói o documento arquivado, para conferi-lo depois.
|
|
127
|
+
|
|
128
|
+
Um documento é uma afirmação sobre um build que já terminou, e quem o
|
|
129
|
+
lê -- um passo de CI, uma revisão de incidente -- precisa recalcular o
|
|
130
|
+
`status` a partir dos digests, e não aceitar o `status` gravado. Um
|
|
131
|
+
campo `"status": "VERIFIED"` num arquivo JSON é editável por qualquer
|
|
132
|
+
um; a comparação entre `source` e `source_after_build` não é.
|
|
133
|
+
|
|
134
|
+
O que não parsear vira campo vazio, o que leva o status a
|
|
135
|
+
`INCOMPLETE` -- nunca a `VERIFIED` por omissão.
|
|
136
|
+
"""
|
|
137
|
+
if not isinstance(raw, dict):
|
|
138
|
+
return BuildProvenance(tag="")
|
|
139
|
+
source = _mapping(raw.get("source"))
|
|
140
|
+
after = _mapping(raw.get("source_after_build"))
|
|
141
|
+
artifact = _mapping(raw.get("artifact"))
|
|
142
|
+
bases = source.get("base_images")
|
|
143
|
+
return BuildProvenance(
|
|
144
|
+
tag=_text(raw.get("tag")),
|
|
145
|
+
source=SourceDigests(
|
|
146
|
+
dockerfile=_text(source.get("dockerfile_sha256")),
|
|
147
|
+
context=_text(source.get("context_sha256")),
|
|
148
|
+
context_files=_count(source.get("context_files")),
|
|
149
|
+
base_images=(
|
|
150
|
+
{str(k): str(v) for k, v in bases.items()} if isinstance(bases, dict) else {}
|
|
151
|
+
),
|
|
152
|
+
git_revision=_text(source.get("git_revision")),
|
|
153
|
+
git_dirty=source.get("git_dirty") is True,
|
|
154
|
+
),
|
|
155
|
+
source_after=SourceDigests(
|
|
156
|
+
dockerfile=_text(after.get("dockerfile_sha256")),
|
|
157
|
+
context=_text(after.get("context_sha256")),
|
|
158
|
+
),
|
|
159
|
+
artifact=ArtifactDigests(
|
|
160
|
+
image_id=_text(artifact.get("image_id")),
|
|
161
|
+
repo_digest=_text(artifact.get("repo_digest")),
|
|
162
|
+
published_reference=_text(artifact.get("published_reference")),
|
|
163
|
+
scanner=_text(artifact.get("scanner")),
|
|
164
|
+
),
|
|
165
|
+
started_at=_text(raw.get("started_at")),
|
|
166
|
+
finished_at=_text(raw.get("finished_at")),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def to_dict(self) -> dict[str, object]:
|
|
170
|
+
"""O documento arquivado junto do relatório.
|
|
171
|
+
|
|
172
|
+
Formato próprio, e propositalmente plano: um consumidor precisa
|
|
173
|
+
conseguir comparar dois builds com um `diff` sem instalar nada.
|
|
174
|
+
"""
|
|
175
|
+
return {
|
|
176
|
+
"tag": self.tag,
|
|
177
|
+
"status": str(self.status),
|
|
178
|
+
"explanation": self.explain(),
|
|
179
|
+
"started_at": self.started_at,
|
|
180
|
+
"finished_at": self.finished_at,
|
|
181
|
+
"source": {
|
|
182
|
+
"dockerfile_sha256": self.source.dockerfile,
|
|
183
|
+
"context_sha256": self.source.context,
|
|
184
|
+
"context_files": self.source.context_files,
|
|
185
|
+
"base_images": dict(self.source.base_images),
|
|
186
|
+
"git_revision": self.source.git_revision,
|
|
187
|
+
"git_dirty": self.source.git_dirty,
|
|
188
|
+
},
|
|
189
|
+
"source_after_build": {
|
|
190
|
+
"dockerfile_sha256": self.source_after.dockerfile,
|
|
191
|
+
"context_sha256": self.source_after.context,
|
|
192
|
+
},
|
|
193
|
+
"artifact": {
|
|
194
|
+
"image_id": self.artifact.image_id,
|
|
195
|
+
"repo_digest": self.artifact.repo_digest,
|
|
196
|
+
"published_reference": self.artifact.published_reference,
|
|
197
|
+
"scanner": self.artifact.scanner,
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _mapping(value: object) -> dict[str, object]:
|
|
203
|
+
return value if isinstance(value, dict) else {}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _text(value: object) -> str:
|
|
207
|
+
return value.strip() if isinstance(value, str) else ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _count(value: object) -> int:
|
|
211
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""O que muda de superfície entre duas receitas de imagem base.
|
|
2
|
+
|
|
3
|
+
O menu do `base-image` faz a pessoa escolher família, runtime e pacotes, e
|
|
4
|
+
cada escolha custa alguma coisa. O custo aparece na hora de marcar o pacote,
|
|
5
|
+
uma linha por vez -- o que não ajuda em nada na pergunta que de fato se faz:
|
|
6
|
+
"alpine ou debian para isto?". Comparar exigia gerar os dois Dockerfiles e ler
|
|
7
|
+
os dois lado a lado, contando pacotes na mão.
|
|
8
|
+
|
|
9
|
+
Este módulo responde a pergunta como diferença: o que a receita B tem que a A
|
|
10
|
+
não tem, o que ela perde, e o que cada uma dessas trocas significa. É puro --
|
|
11
|
+
recebe duas receitas e devolve o delta, sem tocar em registry, disco ou build.
|
|
12
|
+
|
|
13
|
+
**O que ele deliberadamente não faz é eleger uma vencedora.** Contar pacotes
|
|
14
|
+
não mede vulnerabilidade: uma base com menos pacotes e um deles desatualizado
|
|
15
|
+
é pior do que uma com mais pacotes e todos corrigidos, e esta ferramenta
|
|
16
|
+
inteira é construída sobre a recusa de apresentar como medido o que não foi
|
|
17
|
+
medido. O delta descreve as trocas com precisão e manda escanear as duas -- que
|
|
18
|
+
é a única coisa que responde de verdade.
|
|
19
|
+
|
|
20
|
+
A troca de família tem destaque próprio porque é a única que muda o contrato
|
|
21
|
+
binário: alpine é musl, debian e ubuntu são glibc. Uma dependência compilada
|
|
22
|
+
sem roda musllinux não instala do outro lado, e descobrir isso no diff custa
|
|
23
|
+
segundos; descobrir no build de produção custa a janela de deploy.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
|
|
30
|
+
from dockerls.domain.value_objects.base_recipe import PACKAGE_CATALOG, BaseRecipe
|
|
31
|
+
|
|
32
|
+
_BY_KEY = {choice.key: choice for choice in PACKAGE_CATALOG}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class PackageDelta:
|
|
37
|
+
"""Um pacote que existe só de um lado, com o que ele traz junto."""
|
|
38
|
+
|
|
39
|
+
key: str
|
|
40
|
+
purpose: str
|
|
41
|
+
cost: str
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def of(key: str) -> PackageDelta:
|
|
45
|
+
choice = _BY_KEY.get(key)
|
|
46
|
+
if choice is None:
|
|
47
|
+
# Pacote fora do catálogo não deveria chegar aqui (a receita
|
|
48
|
+
# valida antes), mas o diff não é lugar de levantar: descrever o
|
|
49
|
+
# que não se conhece como desconhecido é mais útil que estourar.
|
|
50
|
+
return PackageDelta(key=key, purpose="not catalogued", cost="unknown")
|
|
51
|
+
return PackageDelta(key=choice.key, purpose=choice.purpose, cost=choice.cost)
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, str]:
|
|
54
|
+
return {"package": self.key, "purpose": self.purpose, "cost": self.cost}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class RecipeDiff:
|
|
59
|
+
"""As diferenças entre duas receitas, da esquerda para a direita."""
|
|
60
|
+
|
|
61
|
+
left: BaseRecipe
|
|
62
|
+
right: BaseRecipe
|
|
63
|
+
added: tuple[PackageDelta, ...] = field(default_factory=tuple)
|
|
64
|
+
removed: tuple[PackageDelta, ...] = field(default_factory=tuple)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def family_changed(self) -> bool:
|
|
68
|
+
return self.left.family is not self.right.family
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def libc_changed(self) -> bool:
|
|
72
|
+
"""A troca que quebra binário compilado, e não só tamanho de imagem."""
|
|
73
|
+
return self.left.family.libc != self.right.family.libc
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def runtime_changed(self) -> bool:
|
|
77
|
+
return self.left.runtime is not self.right.runtime
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def manager_strip_changed(self) -> bool:
|
|
81
|
+
return self.left.strip_bundled_manager != self.right.strip_bundled_manager
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def pinning_changed(self) -> bool:
|
|
85
|
+
return bool(self.left.digest) != bool(self.right.digest)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def has_changes(self) -> bool:
|
|
89
|
+
return bool(
|
|
90
|
+
self.added
|
|
91
|
+
or self.removed
|
|
92
|
+
or self.family_changed
|
|
93
|
+
or self.runtime_changed
|
|
94
|
+
or self.manager_strip_changed
|
|
95
|
+
or self.pinning_changed
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def notes(self) -> list[str]:
|
|
99
|
+
"""As trocas que não são pacote, ditas em uma linha cada."""
|
|
100
|
+
lines: list[str] = []
|
|
101
|
+
both_install = self.left.family.installs_packages and self.right.family.installs_packages
|
|
102
|
+
if self.libc_changed:
|
|
103
|
+
lines.append(
|
|
104
|
+
f"libc changes from {self.left.family.libc} to "
|
|
105
|
+
f"{self.right.family.libc}: compiled dependencies need a wheel for the "
|
|
106
|
+
"new one, or they get built from source -- and some simply do not build"
|
|
107
|
+
)
|
|
108
|
+
elif self.family_changed:
|
|
109
|
+
lines.append(
|
|
110
|
+
f"family changes from {self.left.family} to {self.right.family}, "
|
|
111
|
+
"keeping the same libc"
|
|
112
|
+
)
|
|
113
|
+
if self.runtime_changed:
|
|
114
|
+
lines.append(f"runtime changes from {self.left.runtime} to {self.right.runtime}")
|
|
115
|
+
if not both_install:
|
|
116
|
+
distroless = (
|
|
117
|
+
self.right.family if not self.right.family.installs_packages else self.left.family
|
|
118
|
+
)
|
|
119
|
+
lines.append(
|
|
120
|
+
f"{distroless} has no package manager and no shell: nothing can be "
|
|
121
|
+
"installed into it afterwards, and no `docker exec` will work"
|
|
122
|
+
)
|
|
123
|
+
# Numa distroless não há gerenciador embutido para remover: dizer que
|
|
124
|
+
# um lado "remove e o outro não" descreveria uma diferença que não
|
|
125
|
+
# existe, e a nota do distroless logo acima já cobre o caso.
|
|
126
|
+
if self.manager_strip_changed and both_install:
|
|
127
|
+
quem = "the right" if self.right.strip_bundled_manager else "the left"
|
|
128
|
+
lines.append(
|
|
129
|
+
f"only {quem} removes the package manager the official image ships -- "
|
|
130
|
+
"the largest surface difference between the two, and it does not show "
|
|
131
|
+
"up in a package count"
|
|
132
|
+
)
|
|
133
|
+
if self.pinning_changed:
|
|
134
|
+
solta = "the right" if not self.right.digest else "the left"
|
|
135
|
+
lines.append(
|
|
136
|
+
f"{solta} sits on a moving tag, with no digest: two builds of the same "
|
|
137
|
+
"file can produce different images"
|
|
138
|
+
)
|
|
139
|
+
return lines
|
|
140
|
+
|
|
141
|
+
def verdict(self) -> str:
|
|
142
|
+
"""Por que este diff não elege uma vencedora."""
|
|
143
|
+
return (
|
|
144
|
+
"this is a content diff, not a vulnerability diff: counting packages does "
|
|
145
|
+
"not measure CVEs, and the only answer to which of the two is safer comes "
|
|
146
|
+
"from scanning both. Build them and run `dockerls scan` on each"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def to_dict(self) -> dict[str, object]:
|
|
150
|
+
return {
|
|
151
|
+
"left": _describe(self.left),
|
|
152
|
+
"right": _describe(self.right),
|
|
153
|
+
"added": [d.to_dict() for d in self.added],
|
|
154
|
+
"removed": [d.to_dict() for d in self.removed],
|
|
155
|
+
"libc_changed": self.libc_changed,
|
|
156
|
+
"notes": self.notes(),
|
|
157
|
+
"verdict": self.verdict(),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def compare(left: BaseRecipe, right: BaseRecipe) -> RecipeDiff:
|
|
162
|
+
"""As diferenças da receita `left` para a `right`."""
|
|
163
|
+
esquerda = set(left.packages)
|
|
164
|
+
direita = set(right.packages)
|
|
165
|
+
return RecipeDiff(
|
|
166
|
+
left=left,
|
|
167
|
+
right=right,
|
|
168
|
+
added=tuple(PackageDelta.of(k) for k in sorted(direita - esquerda)),
|
|
169
|
+
removed=tuple(PackageDelta.of(k) for k in sorted(esquerda - direita)),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _describe(recipe: BaseRecipe) -> dict[str, object]:
|
|
174
|
+
try:
|
|
175
|
+
reference = recipe.base.reference
|
|
176
|
+
except Exception:
|
|
177
|
+
# Combinação sem imagem publicada: o diff ainda descreve o resto, e
|
|
178
|
+
# `validate()` é quem recusa a receita impossível.
|
|
179
|
+
reference = f"{recipe.runtime} on {recipe.family} (no published image)"
|
|
180
|
+
return {
|
|
181
|
+
"family": str(recipe.family),
|
|
182
|
+
"runtime": str(recipe.runtime),
|
|
183
|
+
"libc": recipe.family.libc,
|
|
184
|
+
"reference": reference,
|
|
185
|
+
"packages": list(recipe.packages),
|
|
186
|
+
"pinned": bool(recipe.digest),
|
|
187
|
+
"strips_bundled_manager": recipe.strip_bundled_manager,
|
|
188
|
+
}
|