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,321 @@
|
|
|
1
|
+
"""Deriva um patch de Dockerfile a partir das vulnerabilidades encontradas.
|
|
2
|
+
|
|
3
|
+
O que esta ferramenta analisa é uma **imagem publicada**, não o Dockerfile de
|
|
4
|
+
quem a executa -- e não há como recuperar um do outro. Então o produto honesto
|
|
5
|
+
não é "o seu Dockerfile corrigido": é um patch que parte da imagem analisada e
|
|
6
|
+
aplica, em camadas, exatamente o que os achados justificam. Quem tem o
|
|
7
|
+
Dockerfile original copia as linhas; quem não tem constrói em cima.
|
|
8
|
+
|
|
9
|
+
Cada ação sai do dado, nunca de um palpite:
|
|
10
|
+
|
|
11
|
+
* pacotes de SO com correção disponível -> `apk upgrade` / `apt-get upgrade`,
|
|
12
|
+
escolhido pela distro que o próprio scanner reportou no `Target`;
|
|
13
|
+
* pacotes de linguagem com correção -> upgrade do gerenciador correspondente;
|
|
14
|
+
* o npm embutido nas imagens Node -> as duas saídas reais (atualizar ou
|
|
15
|
+
remover), porque `apk upgrade` não toca nele;
|
|
16
|
+
* achados sem correção -> nenhuma ação inventada, apenas o registro de que
|
|
17
|
+
sobram e de que só trocar de base resolve.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from dockerls.domain.entities.vulnerability import PackageOrigin
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Iterable, Sequence
|
|
29
|
+
|
|
30
|
+
from dockerls.application.dto.analysis import ImageAnalysis
|
|
31
|
+
from dockerls.domain.entities.vulnerability import Vulnerability
|
|
32
|
+
|
|
33
|
+
# Marcadores de distro no `Target` do Trivy -> comando de upgrade. A ordem
|
|
34
|
+
# importa só para "ubuntu" não casar antes de "debian" em alvos que citam as
|
|
35
|
+
# duas; ambos usam apt, então o resultado é o mesmo de qualquer forma.
|
|
36
|
+
_OS_UPGRADE = (
|
|
37
|
+
("alpine", "apk", "apk upgrade --no-cache"),
|
|
38
|
+
("wolfi", "apk", "apk upgrade --no-cache"),
|
|
39
|
+
("chainguard", "apk", "apk upgrade --no-cache"),
|
|
40
|
+
("debian", "apt", "apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*"),
|
|
41
|
+
("ubuntu", "apt", "apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*"),
|
|
42
|
+
("redhat", "dnf", "dnf upgrade -y && dnf clean all"),
|
|
43
|
+
("centos", "dnf", "dnf upgrade -y && dnf clean all"),
|
|
44
|
+
("rocky", "dnf", "dnf upgrade -y && dnf clean all"),
|
|
45
|
+
("alma", "dnf", "dnf upgrade -y && dnf clean all"),
|
|
46
|
+
("amazon", "dnf", "dnf upgrade -y && dnf clean all"),
|
|
47
|
+
("photon", "tdnf", "tdnf upgrade -y && tdnf clean all"),
|
|
48
|
+
("suse", "zypper", "zypper update -y && zypper clean --all"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Ecossistema de linguagem -> como atualizar um pacote nomeado.
|
|
52
|
+
_LANG_UPGRADE = {
|
|
53
|
+
"npm": "npm install -g {packages}",
|
|
54
|
+
"yarn": "yarn global upgrade {packages}",
|
|
55
|
+
"pip": "pip install --no-cache-dir --upgrade {packages}",
|
|
56
|
+
"gem": "gem update {packages}",
|
|
57
|
+
"composer": "composer global update {packages}",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_LANG_MARKERS = {
|
|
61
|
+
"npm": ("npm", "node-pkg", "node_modules", "yarn"),
|
|
62
|
+
"pip": ("pip", "python-pkg", "site-packages", "poetry"),
|
|
63
|
+
"gem": ("gem", "gemspec", "ruby"),
|
|
64
|
+
"composer": ("composer", "php"),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_NPM_BUNDLE_MARKERS = ("node_modules/npm", "/npm/")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class RemediationAction:
|
|
72
|
+
"""Uma linha do patch, com o motivo que a justifica."""
|
|
73
|
+
|
|
74
|
+
title: str
|
|
75
|
+
dockerfile: str
|
|
76
|
+
rationale: str
|
|
77
|
+
addresses: tuple[str, ...] = ()
|
|
78
|
+
#: Ações que não podem ser aplicadas junto com outra (atualizar o npm
|
|
79
|
+
#: versus removê-lo). O chamador escolhe uma; ambas ficam no patch,
|
|
80
|
+
#: com a alternativa comentada.
|
|
81
|
+
alternative_to: str = ""
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def fixes(self) -> int:
|
|
85
|
+
return len(self.addresses)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class RemediationPlan:
|
|
90
|
+
image_reference: str
|
|
91
|
+
actions: list[RemediationAction] = field(default_factory=list)
|
|
92
|
+
#: CVEs que nenhuma ação deste plano resolve.
|
|
93
|
+
unresolved: tuple[str, ...] = ()
|
|
94
|
+
base_suggestion: str = ""
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def is_empty(self) -> bool:
|
|
98
|
+
return not self.actions and not self.base_suggestion
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def resolved_count(self) -> int:
|
|
102
|
+
"""Achados distintos cobertos por pelo menos uma ação."""
|
|
103
|
+
covered: set[str] = set()
|
|
104
|
+
for action in self.actions:
|
|
105
|
+
covered.update(action.addresses)
|
|
106
|
+
return len(covered)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _ecosystem_of(vuln: Vulnerability) -> str | None:
|
|
110
|
+
haystack = f"{vuln.package_type} {vuln.target}".lower()
|
|
111
|
+
for ecosystem, markers in _LANG_MARKERS.items():
|
|
112
|
+
if any(marker in haystack for marker in markers):
|
|
113
|
+
return ecosystem
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _os_upgrade_for(vulns: Sequence[Vulnerability]) -> tuple[str, str] | None:
|
|
118
|
+
"""(nome do gerenciador, comando) para a distro que o scanner reportou."""
|
|
119
|
+
haystack = " ".join(f"{v.package_type} {v.target}" for v in vulns).lower()
|
|
120
|
+
for marker, manager, command in _OS_UPGRADE:
|
|
121
|
+
if marker in haystack:
|
|
122
|
+
return manager, command
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_bundled_npm(vuln: Vulnerability) -> bool:
|
|
127
|
+
return vuln.origin is PackageOrigin.LANGUAGE and any(
|
|
128
|
+
marker in vuln.target.lower() for marker in _NPM_BUNDLE_MARKERS
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_remediation_plan(analysis: ImageAnalysis) -> RemediationPlan:
|
|
133
|
+
"""Derive the actions the findings actually justify -- and nothing else."""
|
|
134
|
+
vulns = list(analysis.scan.vulnerabilities)
|
|
135
|
+
plan = RemediationPlan(image_reference=analysis.image.full_reference)
|
|
136
|
+
if not vulns:
|
|
137
|
+
return plan
|
|
138
|
+
|
|
139
|
+
fixable = [v for v in vulns if v.is_fixable]
|
|
140
|
+
handled: set[str] = set()
|
|
141
|
+
|
|
142
|
+
# 1. O npm embutido vem primeiro: é o caso em que o upgrade genérico da
|
|
143
|
+
# linguagem não é a melhor resposta, e em que remover resolve tudo.
|
|
144
|
+
bundled_npm = [v for v in vulns if _is_bundled_npm(v)]
|
|
145
|
+
if bundled_npm:
|
|
146
|
+
ids = tuple(v.cve_id for v in bundled_npm)
|
|
147
|
+
plan.actions.append(
|
|
148
|
+
RemediationAction(
|
|
149
|
+
title="Update the bundled npm CLI",
|
|
150
|
+
dockerfile="RUN npm install -g npm@latest",
|
|
151
|
+
rationale=(
|
|
152
|
+
"These findings are in the npm CLI shipped inside the image, not in "
|
|
153
|
+
"OS packages -- apk/apt upgrade does not touch them."
|
|
154
|
+
),
|
|
155
|
+
addresses=ids,
|
|
156
|
+
alternative_to="remove-npm",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
plan.actions.append(
|
|
160
|
+
RemediationAction(
|
|
161
|
+
title="Remove the bundled npm CLI",
|
|
162
|
+
dockerfile=(
|
|
163
|
+
"RUN rm -rf /usr/local/lib/node_modules/npm "
|
|
164
|
+
"/usr/local/bin/npm /usr/local/bin/npx"
|
|
165
|
+
),
|
|
166
|
+
rationale=(
|
|
167
|
+
"If npm is not needed at runtime (multi-stage build shipping only "
|
|
168
|
+
"dist/), removing it clears every one of these findings outright."
|
|
169
|
+
),
|
|
170
|
+
addresses=ids,
|
|
171
|
+
alternative_to="update-npm",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
handled.update(ids)
|
|
175
|
+
|
|
176
|
+
# 2. Pacotes de SO com correção publicada.
|
|
177
|
+
os_fixable = [v for v in fixable if v.origin is PackageOrigin.OS]
|
|
178
|
+
if os_fixable:
|
|
179
|
+
upgrade = _os_upgrade_for(os_fixable)
|
|
180
|
+
if upgrade is not None:
|
|
181
|
+
manager, command = upgrade
|
|
182
|
+
ids = tuple(v.cve_id for v in os_fixable)
|
|
183
|
+
plan.actions.append(
|
|
184
|
+
RemediationAction(
|
|
185
|
+
title=f"Upgrade OS packages ({manager})",
|
|
186
|
+
dockerfile=f"RUN {command}",
|
|
187
|
+
rationale=(
|
|
188
|
+
f"{len(ids)} OS-package finding(s) have a fixed version published upstream."
|
|
189
|
+
),
|
|
190
|
+
addresses=ids,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
handled.update(ids)
|
|
194
|
+
|
|
195
|
+
# 3. Pacotes de linguagem com correção, agrupados por ecossistema. O npm
|
|
196
|
+
# embutido já foi tratado acima e não se repete aqui.
|
|
197
|
+
lang_fixable = [
|
|
198
|
+
v for v in fixable if v.origin is PackageOrigin.LANGUAGE and not _is_bundled_npm(v)
|
|
199
|
+
]
|
|
200
|
+
by_ecosystem: dict[str, list[Vulnerability]] = {}
|
|
201
|
+
for vuln in lang_fixable:
|
|
202
|
+
ecosystem = _ecosystem_of(vuln)
|
|
203
|
+
if ecosystem in _LANG_UPGRADE:
|
|
204
|
+
by_ecosystem.setdefault(str(ecosystem), []).append(vuln)
|
|
205
|
+
|
|
206
|
+
for ecosystem, items in sorted(by_ecosystem.items()):
|
|
207
|
+
# Pin ao alvo corrigido, não um upgrade cego: é o dado que o scanner
|
|
208
|
+
# já entregou, e vale mais que "atualize tudo e torça".
|
|
209
|
+
pinned = sorted({f"{v.package_name}@{v.fixed_version}" for v in items if v.package_name})
|
|
210
|
+
if not pinned:
|
|
211
|
+
continue
|
|
212
|
+
template = _LANG_UPGRADE[ecosystem]
|
|
213
|
+
if ecosystem in ("pip", "gem", "composer"):
|
|
214
|
+
pinned = [p.replace("@", "==" if ecosystem == "pip" else ":") for p in pinned]
|
|
215
|
+
ids = tuple(v.cve_id for v in items)
|
|
216
|
+
plan.actions.append(
|
|
217
|
+
RemediationAction(
|
|
218
|
+
title=f"Upgrade {ecosystem} packages",
|
|
219
|
+
dockerfile=f"RUN {template.format(packages=' '.join(pinned))}",
|
|
220
|
+
rationale=f"{len(ids)} {ecosystem} finding(s) have a published fixed version.",
|
|
221
|
+
addresses=ids,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
handled.update(ids)
|
|
225
|
+
|
|
226
|
+
plan.unresolved = tuple(sorted({v.cve_id for v in vulns} - handled))
|
|
227
|
+
|
|
228
|
+
# 4. Trocar de base só é sugerido quando sobra algo que as camadas acima
|
|
229
|
+
# não resolvem -- caso contrário seria conselho gratuito.
|
|
230
|
+
if plan.unresolved and not (analysis.image.is_alpine or analysis.image.is_distroless):
|
|
231
|
+
plan.base_suggestion = (
|
|
232
|
+
f"{len(plan.unresolved)} finding(s) have no published fix. A minimal base "
|
|
233
|
+
f"(alpine, distroless, or a hardened vendor image) removes the packages "
|
|
234
|
+
f"they live in rather than patching them; `dockerls recommend "
|
|
235
|
+
f"{analysis.image.name}` ranks the alternatives by measured vulnerabilities."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return plan
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def render_dockerfile_patch(plan: RemediationPlan) -> str:
|
|
242
|
+
"""Render the plan as a Dockerfile that starts from the analysed image.
|
|
243
|
+
|
|
244
|
+
Não é "o seu Dockerfile corrigido" -- esta ferramenta nunca viu o seu
|
|
245
|
+
Dockerfile. É um patch aplicável: quem tem o original copia as camadas,
|
|
246
|
+
quem não tem constrói a partir daqui.
|
|
247
|
+
"""
|
|
248
|
+
lines = [
|
|
249
|
+
"# Generated by dockerls --fix",
|
|
250
|
+
f"# Base: {plan.image_reference}",
|
|
251
|
+
"#",
|
|
252
|
+
"# These layers are derived from the findings of a real scan of the image",
|
|
253
|
+
"# above. dockerls analyses published images, not your Dockerfile, so this",
|
|
254
|
+
"# is a patch to apply -- copy the RUN lines into your own build, or build",
|
|
255
|
+
"# from here directly.",
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
if plan.is_empty:
|
|
259
|
+
lines += [
|
|
260
|
+
"#",
|
|
261
|
+
"# Nothing to remediate: no finding in this image has a published fix",
|
|
262
|
+
"# that a layer could apply.",
|
|
263
|
+
"",
|
|
264
|
+
f"FROM {plan.image_reference}",
|
|
265
|
+
"",
|
|
266
|
+
]
|
|
267
|
+
return "\n".join(lines)
|
|
268
|
+
|
|
269
|
+
lines += ["", f"FROM {plan.image_reference}", ""]
|
|
270
|
+
|
|
271
|
+
chosen_alternatives: set[str] = set()
|
|
272
|
+
for action in plan.actions:
|
|
273
|
+
commented = bool(action.alternative_to) and action.alternative_to in chosen_alternatives
|
|
274
|
+
if action.alternative_to:
|
|
275
|
+
chosen_alternatives.add(_action_key(action))
|
|
276
|
+
|
|
277
|
+
lines.append(f"# {action.title} -- fixes {action.fixes} finding(s)")
|
|
278
|
+
for wrapped in _wrap_comment(action.rationale):
|
|
279
|
+
lines.append(f"# {wrapped}")
|
|
280
|
+
if commented:
|
|
281
|
+
lines.append("# Alternative to the layer above -- pick one, not both:")
|
|
282
|
+
lines.append(f"# {action.dockerfile}")
|
|
283
|
+
else:
|
|
284
|
+
lines.append(action.dockerfile)
|
|
285
|
+
lines.append("")
|
|
286
|
+
|
|
287
|
+
if plan.unresolved:
|
|
288
|
+
lines.append(f"# {len(plan.unresolved)} finding(s) remain with no published fix:")
|
|
289
|
+
for cve in plan.unresolved[:20]:
|
|
290
|
+
lines.append(f"# {cve}")
|
|
291
|
+
if len(plan.unresolved) > 20:
|
|
292
|
+
lines.append(f"# ... and {len(plan.unresolved) - 20} more")
|
|
293
|
+
lines.append("")
|
|
294
|
+
|
|
295
|
+
if plan.base_suggestion:
|
|
296
|
+
for wrapped in _wrap_comment(plan.base_suggestion):
|
|
297
|
+
lines.append(f"# {wrapped}")
|
|
298
|
+
lines.append("")
|
|
299
|
+
|
|
300
|
+
return "\n".join(lines)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _action_key(action: RemediationAction) -> str:
|
|
304
|
+
"""A ação que a alternativa dela referencia por nome."""
|
|
305
|
+
return "update-npm" if "install -g npm" in action.dockerfile else "remove-npm"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _wrap_comment(text: str, width: int = 72) -> list[str]:
|
|
309
|
+
words: Iterable[str] = text.split()
|
|
310
|
+
lines: list[str] = []
|
|
311
|
+
current = ""
|
|
312
|
+
for word in words:
|
|
313
|
+
candidate = f"{current} {word}".strip()
|
|
314
|
+
if len(candidate) > width and current:
|
|
315
|
+
lines.append(current)
|
|
316
|
+
current = word
|
|
317
|
+
else:
|
|
318
|
+
current = candidate
|
|
319
|
+
if current:
|
|
320
|
+
lines.append(current)
|
|
321
|
+
return lines
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Where an image's vulnerability-count history persists between runs.
|
|
2
|
+
|
|
3
|
+
Mirrors `tag_history_store.py` exactly, for the same reason its own module
|
|
4
|
+
docstring gives: the cache that scores lean on expires because a stale
|
|
5
|
+
score is worse than none, while a history *is* the past and is worth more
|
|
6
|
+
the longer it has been kept. A year-long TTL, renewed on every write, is
|
|
7
|
+
what lets the second observation ever recorded still answer "since when".
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
from dockerls.domain.value_objects.scan_history import ScanHistory, ScanObservation, record
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from dockerls.domain.interfaces.cache_store import CacheStoreInterface
|
|
21
|
+
|
|
22
|
+
#: A year, matching `tag_history_store.HISTORY_TTL_SECONDS`: this is the
|
|
23
|
+
#: past, and it does not go stale.
|
|
24
|
+
HISTORY_TTL_SECONDS = 365 * 24 * 60 * 60
|
|
25
|
+
|
|
26
|
+
_KEY_PREFIX = "scan-history"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ScanHistoryStore:
|
|
30
|
+
"""Reads and writes vulnerability-count history, keyed by reference."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, cache: CacheStoreInterface | None = None):
|
|
33
|
+
self._cache = cache
|
|
34
|
+
|
|
35
|
+
async def get(self, reference: str) -> ScanHistory:
|
|
36
|
+
"""The stored history, or an empty one when there is none (or it
|
|
37
|
+
could not be read)."""
|
|
38
|
+
if self._cache is None or not reference:
|
|
39
|
+
return ScanHistory(reference=reference)
|
|
40
|
+
try:
|
|
41
|
+
raw = await self._cache.get(_key(reference))
|
|
42
|
+
except Exception as e: # pragma: no cover - the cache is the unstable path
|
|
43
|
+
logger.debug(f"Could not read the scan history of {reference}: {e}")
|
|
44
|
+
return ScanHistory(reference=reference)
|
|
45
|
+
return ScanHistory.from_dict(reference, raw)
|
|
46
|
+
|
|
47
|
+
async def observe(
|
|
48
|
+
self,
|
|
49
|
+
reference: str,
|
|
50
|
+
*,
|
|
51
|
+
digest: str,
|
|
52
|
+
critical: int,
|
|
53
|
+
high: int,
|
|
54
|
+
medium: int,
|
|
55
|
+
low: int,
|
|
56
|
+
total: int,
|
|
57
|
+
observed_at: datetime | None = None,
|
|
58
|
+
) -> ScanHistory:
|
|
59
|
+
"""Incorporates this scan's counts and returns the resulting history.
|
|
60
|
+
|
|
61
|
+
Writes only when something changed: rewriting an unchanged entry on
|
|
62
|
+
every query would do nothing but renew the TTL, which the write
|
|
63
|
+
that matters already does.
|
|
64
|
+
"""
|
|
65
|
+
current = await self.get(reference)
|
|
66
|
+
observation = ScanObservation(
|
|
67
|
+
digest=digest,
|
|
68
|
+
observed_at=(observed_at or datetime.now(UTC)).isoformat(timespec="seconds"),
|
|
69
|
+
critical=critical,
|
|
70
|
+
high=high,
|
|
71
|
+
medium=medium,
|
|
72
|
+
low=low,
|
|
73
|
+
total=total,
|
|
74
|
+
)
|
|
75
|
+
updated = record(current, observation)
|
|
76
|
+
if updated is current or self._cache is None:
|
|
77
|
+
return updated
|
|
78
|
+
try:
|
|
79
|
+
await self._cache.set(
|
|
80
|
+
_key(reference), updated.to_dict(), ttl_seconds=HISTORY_TTL_SECONDS
|
|
81
|
+
)
|
|
82
|
+
except Exception as e: # pragma: no cover - the cache is the unstable path
|
|
83
|
+
logger.debug(f"Could not write the scan history of {reference}: {e}")
|
|
84
|
+
return updated
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _key(reference: str) -> str:
|
|
88
|
+
return f"{_KEY_PREFIX}:{reference}"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from dockerls.application.services.fallback_scanner import FallbackScanner
|
|
8
|
+
from dockerls.integrations.grype.scanner import GrypeScanner
|
|
9
|
+
from dockerls.integrations.trivy.scanner import TrivyScanner
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from dockerls.domain.interfaces.scanner import ScannerInterface
|
|
15
|
+
from dockerls.infrastructure.evidence import EvidenceStore
|
|
16
|
+
from dockerls.infrastructure.network.host_guard import HostGuard
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ScannerFactory:
|
|
20
|
+
@staticmethod
|
|
21
|
+
async def create(
|
|
22
|
+
timeout: int = 300,
|
|
23
|
+
workers: int = 1,
|
|
24
|
+
cache_dir: Path | None = None,
|
|
25
|
+
evidence: EvidenceStore | None = None,
|
|
26
|
+
guard: HostGuard | None = None,
|
|
27
|
+
) -> ScannerInterface:
|
|
28
|
+
"""Build the scanner the pipeline will use.
|
|
29
|
+
|
|
30
|
+
When both tools are installed the result is a `FallbackScanner`: the
|
|
31
|
+
Trivy runs first and the Grype picks up whatever it could not measure.
|
|
32
|
+
Selecting on `is_available()` alone -- which is just `shutil.which` --
|
|
33
|
+
meant an installed-but-broken Trivy monopolised every scan and the
|
|
34
|
+
Grype was never asked, no matter how many targets failed.
|
|
35
|
+
"""
|
|
36
|
+
trivy = TrivyScanner(
|
|
37
|
+
timeout=timeout,
|
|
38
|
+
workers=workers,
|
|
39
|
+
cache_dir=cache_dir,
|
|
40
|
+
evidence=evidence,
|
|
41
|
+
guard=guard,
|
|
42
|
+
)
|
|
43
|
+
grype = GrypeScanner(timeout=timeout, evidence=evidence, guard=guard, workers=workers)
|
|
44
|
+
has_trivy = await trivy.is_available()
|
|
45
|
+
has_grype = await grype.is_available()
|
|
46
|
+
|
|
47
|
+
if has_trivy and has_grype:
|
|
48
|
+
logger.info("Using Trivy, with Grype as per-scan fallback")
|
|
49
|
+
return FallbackScanner(primary=trivy, secondary=grype)
|
|
50
|
+
if has_trivy:
|
|
51
|
+
logger.info("Using Trivy scanner (Grype not installed; no fallback available)")
|
|
52
|
+
return trivy
|
|
53
|
+
if has_grype:
|
|
54
|
+
logger.info("Trivy not installed, using Grype")
|
|
55
|
+
return grype
|
|
56
|
+
|
|
57
|
+
logger.warning("No scanner available, using Trivy (commands will fail)")
|
|
58
|
+
return trivy
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
async def create_secondary(
|
|
62
|
+
primary: ScannerInterface,
|
|
63
|
+
timeout: int = 300,
|
|
64
|
+
evidence: EvidenceStore | None = None,
|
|
65
|
+
guard: HostGuard | None = None,
|
|
66
|
+
workers: int = 1,
|
|
67
|
+
) -> ScannerInterface | None:
|
|
68
|
+
"""Return an *independent* scanner for cross-validation.
|
|
69
|
+
|
|
70
|
+
Cross-validation is only meaningful between two different tools, so
|
|
71
|
+
this returns None when the only available scanner is the one already
|
|
72
|
+
producing the primary results.
|
|
73
|
+
"""
|
|
74
|
+
# Um `FallbackScanner` já usa os dois: revalidar com qualquer um deles
|
|
75
|
+
# confrontaria um resultado com a ferramenta que possivelmente o
|
|
76
|
+
# produziu, o que não é validação independente nenhuma.
|
|
77
|
+
if isinstance(primary, FallbackScanner):
|
|
78
|
+
primary = primary.primary
|
|
79
|
+
|
|
80
|
+
if isinstance(primary, GrypeScanner):
|
|
81
|
+
trivy = TrivyScanner(timeout=timeout, evidence=evidence, guard=guard, workers=workers)
|
|
82
|
+
return trivy if await trivy.is_available() else None
|
|
83
|
+
|
|
84
|
+
grype = GrypeScanner(timeout=timeout, evidence=evidence, guard=guard, workers=workers)
|
|
85
|
+
if await grype.is_available():
|
|
86
|
+
return grype
|
|
87
|
+
logger.info("Grype not installed; cross-validation disabled")
|
|
88
|
+
return None
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Named image sources, resolved from a selection instead of a branch.
|
|
2
|
+
|
|
3
|
+
`recommend` and `search` both need to answer "which catalogues should this
|
|
4
|
+
run look at", and the answer comes from three places at once: the config
|
|
5
|
+
file, the legacy `--no-hardened` flag, and the new `--source`/`--all-sources`
|
|
6
|
+
options. Written as conditionals that ask `if source == "dhi"`, that logic
|
|
7
|
+
would have to be repeated in every command and extended in every command
|
|
8
|
+
each time a catalogue is added.
|
|
9
|
+
|
|
10
|
+
So the catalogues register themselves here, and a command asks for a
|
|
11
|
+
*selection*. Adding a provider is one `register()` call in the wiring layer;
|
|
12
|
+
no command changes, and no branch anywhere grows a new arm.
|
|
13
|
+
|
|
14
|
+
The registry deliberately knows nothing about HTTP, registries or scanners:
|
|
15
|
+
a `SourceSpec` carries a name, a label and a coroutine that builds the
|
|
16
|
+
repository. That keeps the application layer free of `httpx` while still
|
|
17
|
+
letting the CLI enumerate what exists (`--source` help text, `doctor`).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
|
27
|
+
|
|
28
|
+
from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
|
|
29
|
+
|
|
30
|
+
SourceBuilder = Callable[[], Awaitable[ImageRepositoryInterface]]
|
|
31
|
+
|
|
32
|
+
#: `--source all` and `--all-sources` mean the same thing; accepting the
|
|
33
|
+
#: token as well keeps `--source` self-sufficient in scripts.
|
|
34
|
+
ALL_SOURCES = "all"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UnknownSourceError(ValueError):
|
|
38
|
+
"""Raised for a `--source` value no provider is registered under.
|
|
39
|
+
|
|
40
|
+
Carries the valid names so the CLI can say what *is* available instead
|
|
41
|
+
of only what is not.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, unknown: Iterable[str], available: Iterable[str]):
|
|
45
|
+
self.unknown = sorted(set(unknown))
|
|
46
|
+
self.available = list(available)
|
|
47
|
+
super().__init__(
|
|
48
|
+
f"unknown source(s): {', '.join(self.unknown)}. "
|
|
49
|
+
f"Available: {', '.join([*self.available, ALL_SOURCES])}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class SourceSpec:
|
|
55
|
+
"""One catalogue DockerLs can discover candidates in."""
|
|
56
|
+
|
|
57
|
+
#: CLI token, lowercase and stable: this is what `--source` accepts.
|
|
58
|
+
name: str
|
|
59
|
+
#: Human label carried on every `DockerImage` this source produces, and
|
|
60
|
+
#: shown in the results table. Must match what the repository sets, or
|
|
61
|
+
#: the table would credit a candidate to the wrong catalogue.
|
|
62
|
+
label: str
|
|
63
|
+
#: Builds the repository. Async because some sources authenticate.
|
|
64
|
+
build: SourceBuilder
|
|
65
|
+
#: The source that sets the bulk of the candidate list. Exactly one
|
|
66
|
+
#: should be primary; it receives the full `--limit` while the others
|
|
67
|
+
#: are capped, because a broad catalogue and a curated one need very
|
|
68
|
+
#: different fan-outs.
|
|
69
|
+
primary: bool = False
|
|
70
|
+
#: Searched when no explicit `--source` is given.
|
|
71
|
+
default_enabled: bool = True
|
|
72
|
+
#: The registry behind this catalogue refuses anonymous pulls, so its
|
|
73
|
+
#: candidates cannot be scanned without credentials. Not a reason to
|
|
74
|
+
#: hide the source -- it is a reason the verdict will be UNVERIFIED,
|
|
75
|
+
#: which is exactly what the user needs to be told.
|
|
76
|
+
requires_auth: bool = False
|
|
77
|
+
#: Short note surfaced next to the source name in `--help`/`doctor`.
|
|
78
|
+
description: str = ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class SourceRegistry:
|
|
83
|
+
"""The catalogues this process knows about, in registration order."""
|
|
84
|
+
|
|
85
|
+
_specs: dict[str, SourceSpec] = field(default_factory=dict)
|
|
86
|
+
|
|
87
|
+
def register(self, spec: SourceSpec) -> None:
|
|
88
|
+
if spec.name in self._specs:
|
|
89
|
+
raise ValueError(f"source already registered: {spec.name}")
|
|
90
|
+
if spec.name == ALL_SOURCES:
|
|
91
|
+
raise ValueError(f"{ALL_SOURCES!r} is reserved and cannot name a source")
|
|
92
|
+
self._specs[spec.name] = spec
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def specs(self) -> list[SourceSpec]:
|
|
96
|
+
return list(self._specs.values())
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def names(self) -> list[str]:
|
|
100
|
+
return list(self._specs)
|
|
101
|
+
|
|
102
|
+
def get(self, name: str) -> SourceSpec | None:
|
|
103
|
+
return self._specs.get(name.strip().lower())
|
|
104
|
+
|
|
105
|
+
def resolve(
|
|
106
|
+
self,
|
|
107
|
+
selection: Sequence[str] | None = None,
|
|
108
|
+
*,
|
|
109
|
+
all_sources: bool = False,
|
|
110
|
+
include_optional: bool = True,
|
|
111
|
+
) -> list[SourceSpec]:
|
|
112
|
+
"""Which sources this run should search.
|
|
113
|
+
|
|
114
|
+
* `all_sources` (or a selection containing `all`) -- every registered
|
|
115
|
+
source, including the ones that are off by default.
|
|
116
|
+
* an explicit selection -- exactly those, in registration order so
|
|
117
|
+
the primary stays first regardless of the order they were typed.
|
|
118
|
+
An explicit selection overrides `include_optional`: asking for a
|
|
119
|
+
source by name is a stronger statement than a default.
|
|
120
|
+
* neither -- the default-enabled sources, minus the non-primary ones
|
|
121
|
+
when `include_optional` is False (that is `--no-hardened`).
|
|
122
|
+
|
|
123
|
+
Order matters downstream: the first spec becomes the composite's
|
|
124
|
+
primary and therefore gets the full tag limit.
|
|
125
|
+
"""
|
|
126
|
+
requested = [s.strip().lower() for s in (selection or []) if s.strip()]
|
|
127
|
+
if all_sources or ALL_SOURCES in requested:
|
|
128
|
+
return self._ordered(self._specs)
|
|
129
|
+
|
|
130
|
+
if requested:
|
|
131
|
+
unknown = [name for name in requested if name not in self._specs]
|
|
132
|
+
if unknown:
|
|
133
|
+
raise UnknownSourceError(unknown, self.names)
|
|
134
|
+
return self._ordered({name: self._specs[name] for name in requested})
|
|
135
|
+
|
|
136
|
+
chosen = {
|
|
137
|
+
name: spec
|
|
138
|
+
for name, spec in self._specs.items()
|
|
139
|
+
if spec.default_enabled and (include_optional or spec.primary)
|
|
140
|
+
}
|
|
141
|
+
return self._ordered(chosen)
|
|
142
|
+
|
|
143
|
+
def _ordered(self, chosen: dict[str, SourceSpec]) -> list[SourceSpec]:
|
|
144
|
+
"""Registration order, primary first.
|
|
145
|
+
|
|
146
|
+
`CompositeImageRepository` gives its primary the full limit and caps
|
|
147
|
+
the rest, so which spec lands in position zero is a behavioural
|
|
148
|
+
decision, not a cosmetic one.
|
|
149
|
+
"""
|
|
150
|
+
selected = [spec for name, spec in self._specs.items() if name in chosen]
|
|
151
|
+
return sorted(selected, key=lambda spec: not spec.primary)
|