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,193 @@
|
|
|
1
|
+
"""Quais tags medir, quando medir todas custa minutos.
|
|
2
|
+
|
|
3
|
+
`recommend node` descobre até `max_tags` tags e mostra cinco. Com o padrão
|
|
4
|
+
de 100 e o Trivy a 1,2-2,5s por imagem, isso são dois a quatro minutos de
|
|
5
|
+
relógio -- e 95 dos 100 scans existem só para serem descartados no
|
|
6
|
+
ranqueamento.
|
|
7
|
+
|
|
8
|
+
O corte podia ser feito de dois jeitos, e só um deles é honesto neste
|
|
9
|
+
projeto:
|
|
10
|
+
|
|
11
|
+
* **baixar `max_tags`** faria uma tag boa fora da janela simplesmente não
|
|
12
|
+
existir, sem nada dizendo que ela existiu;
|
|
13
|
+
* **escolher quem medir, e declarar quem ficou de fora**, que é o que este
|
|
14
|
+
módulo faz.
|
|
15
|
+
|
|
16
|
+
A distinção que sustenta tudo aqui: uma tag adiada **não é uma tag pior**.
|
|
17
|
+
Ela é uma tag *não medida*, e o resultado a carrega com o motivo, do mesmo
|
|
18
|
+
jeito que `UnverifiedImage` carrega um scan que falhou. Em nenhum momento a
|
|
19
|
+
ausência de medição é gasta como se fosse medição.
|
|
20
|
+
|
|
21
|
+
Nenhuma regra aqui consulta rede, disco ou scanner: todas usam fatos que a
|
|
22
|
+
listagem de tags já trouxe. Uma função pura de decisão se testa
|
|
23
|
+
exaustivamente, e é isso que torna defensável cortar 75 scans.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from enum import StrEnum
|
|
31
|
+
from typing import TYPE_CHECKING
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Sequence
|
|
35
|
+
|
|
36
|
+
from dockerls.domain.entities.image import DockerImage
|
|
37
|
+
|
|
38
|
+
#: Quantas tags medir quando nada é configurado. `TOP_N` é 5 e a
|
|
39
|
+
#: verificação final olha 10 finalistas, então 25 deixa folga de sobra para
|
|
40
|
+
#: o ranqueamento ter de quem escolher.
|
|
41
|
+
DEFAULT_SCAN_BUDGET = 25
|
|
42
|
+
|
|
43
|
+
#: Versão no começo da tag: `22`, `22.14`, `3.21.0`. O resto, depois do
|
|
44
|
+
#: primeiro `-`, é a variante (`alpine`, `bookworm-slim`, `jre-alpine`).
|
|
45
|
+
_VERSION_PREFIX = re.compile(r"^v?(\d+(?:\.\d+)*)(?:[-_.](.*))?$", re.IGNORECASE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DeferralReason(StrEnum):
|
|
49
|
+
"""Por que uma tag não foi medida. Nunca "por ser pior"."""
|
|
50
|
+
|
|
51
|
+
#: Existe uma tag mais nova da mesma linha, com a mesma precisão de
|
|
52
|
+
#: versão e a mesma variante -- `22.13-alpine` diante de `22.14-alpine`.
|
|
53
|
+
SUPERSEDED = "SUPERSEDED"
|
|
54
|
+
#: Coube fora do orçamento de scans deste run.
|
|
55
|
+
OVER_BUDGET = "OVER_BUDGET"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class DeferredTag:
|
|
60
|
+
"""Uma tag que existe, foi descoberta, e não foi medida."""
|
|
61
|
+
|
|
62
|
+
reference: str
|
|
63
|
+
reason: DeferralReason
|
|
64
|
+
#: Frase para o humano. Diz o fato que motivou o adiamento, e nunca
|
|
65
|
+
#: uma conclusão sobre a segurança da imagem.
|
|
66
|
+
detail: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class ScanPlan:
|
|
71
|
+
"""O que este run vai medir, e o que ele declaradamente não vai."""
|
|
72
|
+
|
|
73
|
+
selected: list[DockerImage]
|
|
74
|
+
deferred: list[DeferredTag] = field(default_factory=list)
|
|
75
|
+
#: Quantas tags a busca trouxe, antes de qualquer corte.
|
|
76
|
+
discovered: int = 0
|
|
77
|
+
#: O orçamento em vigor; 0 significa "medir todas".
|
|
78
|
+
budget: int = 0
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def deferred_count(self) -> int:
|
|
82
|
+
return len(self.deferred)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _version_and_variant(tag: str) -> tuple[tuple[int, ...] | None, str]:
|
|
86
|
+
"""A versão numérica no início da tag e a variante que sobra.
|
|
87
|
+
|
|
88
|
+
`22.14-alpine` -> `((22, 14), "alpine")`. Uma tag sem versão numérica
|
|
89
|
+
(`latest`, `lts`, `bookworm`) devolve `None`, e tags assim nunca são
|
|
90
|
+
adiadas por sucessão: não há como ordenar o que não tem número.
|
|
91
|
+
"""
|
|
92
|
+
match = _VERSION_PREFIX.match(tag.strip())
|
|
93
|
+
if match is None:
|
|
94
|
+
return None, tag.strip().lower()
|
|
95
|
+
version = tuple(int(part) for part in match.group(1).split("."))
|
|
96
|
+
return version, (match.group(2) or "").lower()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _superseded(tags: Sequence[DockerImage]) -> dict[str, DeferredTag]:
|
|
100
|
+
"""As tags que uma irmã mais nova da mesma linha já representa.
|
|
101
|
+
|
|
102
|
+
"Mesma linha" é: mesma variante, mesmo major, e mesma quantidade de
|
|
103
|
+
componentes de versão. As duas primeiras condições são a linha em si;
|
|
104
|
+
a terceira é o que impede colapsar `22-alpine` com `22.14-alpine`, que
|
|
105
|
+
são respostas diferentes para perguntas diferentes -- uma acompanha a
|
|
106
|
+
linha, a outra fixa o patch, e escolher entre as duas é do usuário.
|
|
107
|
+
|
|
108
|
+
Majors não competem entre si: `20-alpine` continua sendo uma resposta
|
|
109
|
+
legítima ao lado de `22-alpine`, e frequentemente é a certa (LTS).
|
|
110
|
+
"""
|
|
111
|
+
newest: dict[tuple[str, int, tuple[int, ...]], DockerImage] = {}
|
|
112
|
+
for image in tags:
|
|
113
|
+
version, variant = _version_and_variant(image.tag)
|
|
114
|
+
if version is None:
|
|
115
|
+
continue
|
|
116
|
+
key = (variant, len(version), version[:1])
|
|
117
|
+
current = newest.get(key)
|
|
118
|
+
if current is None:
|
|
119
|
+
newest[key] = image
|
|
120
|
+
continue
|
|
121
|
+
current_version, _ = _version_and_variant(current.tag)
|
|
122
|
+
if current_version is not None and version > current_version:
|
|
123
|
+
newest[key] = image
|
|
124
|
+
|
|
125
|
+
winners = {id(image) for image in newest.values()}
|
|
126
|
+
deferred: dict[str, DeferredTag] = {}
|
|
127
|
+
for image in tags:
|
|
128
|
+
version, variant = _version_and_variant(image.tag)
|
|
129
|
+
if version is None or id(image) in winners:
|
|
130
|
+
continue
|
|
131
|
+
key = (variant, len(version), version[:1])
|
|
132
|
+
winner = newest[key]
|
|
133
|
+
deferred[image.full_reference] = DeferredTag(
|
|
134
|
+
reference=image.full_reference,
|
|
135
|
+
reason=DeferralReason.SUPERSEDED,
|
|
136
|
+
detail=(f"a newer tag of the same line was measured instead ({winner.tag})"),
|
|
137
|
+
)
|
|
138
|
+
return deferred
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _priority(image: DockerImage) -> tuple[int, float, str]:
|
|
142
|
+
"""Ordem de preferência para gastar o orçamento.
|
|
143
|
+
|
|
144
|
+
Critérios que a listagem já conhece, sem scan: imagem oficial primeiro,
|
|
145
|
+
depois a mais recentemente publicada, depois o nome -- este último só
|
|
146
|
+
para que dois runs sobre os mesmos dados escolham as mesmas tags.
|
|
147
|
+
"""
|
|
148
|
+
published = image.last_updated.timestamp() if image.last_updated is not None else 0.0
|
|
149
|
+
return (0 if image.is_official else 1, -published, image.full_reference)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def plan_scans(tags: Sequence[DockerImage], budget: int = DEFAULT_SCAN_BUDGET) -> ScanPlan:
|
|
153
|
+
"""Escolhe quem medir, e nomeia quem ficou de fora.
|
|
154
|
+
|
|
155
|
+
`budget <= 0` mede tudo, que é o comportamento de sempre e continua
|
|
156
|
+
disponível por configuração -- quem precisa da varredura completa não
|
|
157
|
+
perdeu nada.
|
|
158
|
+
"""
|
|
159
|
+
discovered = len(tags)
|
|
160
|
+
if budget <= 0 or discovered <= budget:
|
|
161
|
+
return ScanPlan(
|
|
162
|
+
selected=list(tags), deferred=[], discovered=discovered, budget=max(0, budget)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
superseded = _superseded(tags)
|
|
166
|
+
survivors = [image for image in tags if image.full_reference not in superseded]
|
|
167
|
+
|
|
168
|
+
deferred = [
|
|
169
|
+
superseded[image.full_reference] for image in tags if image.full_reference in superseded
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
if len(survivors) > budget:
|
|
173
|
+
ranked = sorted(survivors, key=_priority)
|
|
174
|
+
kept = set(map(id, ranked[:budget]))
|
|
175
|
+
survivors = [image for image in tags if id(image) in kept]
|
|
176
|
+
for image in ranked[budget:]:
|
|
177
|
+
deferred.append(
|
|
178
|
+
DeferredTag(
|
|
179
|
+
reference=image.full_reference,
|
|
180
|
+
reason=DeferralReason.OVER_BUDGET,
|
|
181
|
+
detail=f"beyond this run's budget of {budget} scans",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# A ordem de saída é a da descoberta: o ranqueamento por segurança
|
|
186
|
+
# acontece depois, sobre medições, e reordenar aqui embaralharia a
|
|
187
|
+
# entrada de uma decisão que nada tem a ver com esta.
|
|
188
|
+
return ScanPlan(
|
|
189
|
+
selected=survivors,
|
|
190
|
+
deferred=deferred,
|
|
191
|
+
discovered=discovered,
|
|
192
|
+
budget=budget,
|
|
193
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Há quanto tempo o medidor foi atualizado, e o que isso permite afirmar.
|
|
2
|
+
|
|
3
|
+
`doctor` conferia que o scanner **existe**. Não conferia que ele mede: um
|
|
4
|
+
Trivy com base de vulnerabilidades de três semanas produz um scan limpo,
|
|
5
|
+
verde e sem erro nenhum que simplesmente não conhece os CVEs do último mês.
|
|
6
|
+
|
|
7
|
+
É a falha de medição mais silenciosa que existe aqui, e a mais perigosa
|
|
8
|
+
justamente por ser silenciosa -- nada no relatório indica que a resposta
|
|
9
|
+
está velha. É o tema central do projeto ("ausência de medição nunca é gasta
|
|
10
|
+
como tranquilidade") aplicado ao próprio medidor.
|
|
11
|
+
|
|
12
|
+
O tristate aparece de novo, e pela mesma razão de sempre: não conseguir ler
|
|
13
|
+
a data da base **não** é a base estar fresca. `UNKNOWN` é o valor honesto
|
|
14
|
+
para um arquivo de metadados ausente, ilegível ou com um formato que este
|
|
15
|
+
código não reconhece, e ele nunca é apresentado como aprovação.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import UTC, datetime, timedelta
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
|
|
24
|
+
#: Até aqui, a base cobre o que se publicou recentemente. O Trivy reconstrói
|
|
25
|
+
#: a dele a cada 6h e carimba `NextUpdate` cerca de 24h à frente; o Grype
|
|
26
|
+
#: publica diariamente. Um dia é a janela normal dos dois.
|
|
27
|
+
FRESH_WITHIN = timedelta(hours=24)
|
|
28
|
+
|
|
29
|
+
#: Daqui em diante a base já perdeu dias de publicação. Não é inutilizável
|
|
30
|
+
#: -- a maioria dos CVEs de uma imagem é antiga --, mas quem está decidindo
|
|
31
|
+
#: um deploy precisa saber que a resposta tem essa idade.
|
|
32
|
+
STALE_AFTER = timedelta(hours=72)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DatabaseState(StrEnum):
|
|
36
|
+
FRESH = "FRESH"
|
|
37
|
+
#: Entre um e três dias. Ainda mede, e já vale dizer a idade.
|
|
38
|
+
AGING = "AGING"
|
|
39
|
+
STALE = "STALE"
|
|
40
|
+
#: Não foi possível ler a data. **Não** é o mesmo que fresca.
|
|
41
|
+
UNKNOWN = "UNKNOWN"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class DatabaseFreshness:
|
|
46
|
+
"""O que se sabe sobre a idade da base de um scanner."""
|
|
47
|
+
|
|
48
|
+
scanner: str
|
|
49
|
+
state: DatabaseState
|
|
50
|
+
#: Quando a base foi construída, quando se conseguiu ler.
|
|
51
|
+
built_at: datetime | None = None
|
|
52
|
+
#: Idade no momento da checagem.
|
|
53
|
+
age: timedelta | None = None
|
|
54
|
+
#: Por que não foi possível ler, quando não foi.
|
|
55
|
+
detail: str = ""
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_usable_measurement(self) -> bool:
|
|
59
|
+
"""Se um scan feito agora mediria o que se publicou recentemente.
|
|
60
|
+
|
|
61
|
+
`UNKNOWN` responde `False` de propósito: a pergunta é "dá para
|
|
62
|
+
confiar na atualidade desta base", e "não sei" não é sim.
|
|
63
|
+
"""
|
|
64
|
+
return self.state in (DatabaseState.FRESH, DatabaseState.AGING)
|
|
65
|
+
|
|
66
|
+
def explain(self) -> str:
|
|
67
|
+
"""A frase que diz o que se sabe, sem arredondar para o lado bom."""
|
|
68
|
+
match self.state:
|
|
69
|
+
case DatabaseState.FRESH:
|
|
70
|
+
return f"updated {_human(self.age)} ago"
|
|
71
|
+
case DatabaseState.AGING:
|
|
72
|
+
return (
|
|
73
|
+
f"updated {_human(self.age)} ago: still usable, and already missing "
|
|
74
|
+
"whatever was published since"
|
|
75
|
+
)
|
|
76
|
+
case DatabaseState.STALE:
|
|
77
|
+
return (
|
|
78
|
+
f"updated {_human(self.age)} ago. A scan against it comes back clean "
|
|
79
|
+
"for anything published since, which reads exactly like a clean "
|
|
80
|
+
"image and is not one"
|
|
81
|
+
)
|
|
82
|
+
case DatabaseState.UNKNOWN:
|
|
83
|
+
reason = self.detail or "no readable metadata"
|
|
84
|
+
return (
|
|
85
|
+
f"age unknown ({reason}). This is not the same as up to date: "
|
|
86
|
+
"nothing here says how old the answers are"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, object]:
|
|
90
|
+
return {
|
|
91
|
+
"scanner": self.scanner,
|
|
92
|
+
"state": str(self.state),
|
|
93
|
+
"built_at": self.built_at.isoformat() if self.built_at else "",
|
|
94
|
+
"age_hours": round(self.age.total_seconds() / 3600, 1) if self.age else None,
|
|
95
|
+
"explanation": self.explain(),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def classify(
|
|
100
|
+
scanner: str,
|
|
101
|
+
built_at: datetime | None,
|
|
102
|
+
*,
|
|
103
|
+
now: datetime | None = None,
|
|
104
|
+
detail: str = "",
|
|
105
|
+
) -> DatabaseFreshness:
|
|
106
|
+
"""Classifica a idade de uma base a partir da data em que foi construída.
|
|
107
|
+
|
|
108
|
+
Puro: recebe a data já lida, para que a regra se teste sem tocar disco.
|
|
109
|
+
"""
|
|
110
|
+
if built_at is None:
|
|
111
|
+
return DatabaseFreshness(scanner, DatabaseState.UNKNOWN, detail=detail)
|
|
112
|
+
|
|
113
|
+
moment = now or datetime.now(tz=UTC)
|
|
114
|
+
stamped = built_at if built_at.tzinfo is not None else built_at.replace(tzinfo=UTC)
|
|
115
|
+
age = moment - stamped
|
|
116
|
+
|
|
117
|
+
if age < timedelta(0):
|
|
118
|
+
# Uma base "do futuro" é relógio errado numa das duas pontas, e
|
|
119
|
+
# tratá-la como fresquíssima esconderia o problema.
|
|
120
|
+
return DatabaseFreshness(
|
|
121
|
+
scanner,
|
|
122
|
+
DatabaseState.UNKNOWN,
|
|
123
|
+
built_at=stamped,
|
|
124
|
+
detail="the database is stamped in the future; check the clock",
|
|
125
|
+
)
|
|
126
|
+
if age <= FRESH_WITHIN:
|
|
127
|
+
state = DatabaseState.FRESH
|
|
128
|
+
elif age <= STALE_AFTER:
|
|
129
|
+
state = DatabaseState.AGING
|
|
130
|
+
else:
|
|
131
|
+
state = DatabaseState.STALE
|
|
132
|
+
return DatabaseFreshness(scanner, state, built_at=stamped, age=age)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _human(age: timedelta | None) -> str:
|
|
136
|
+
if age is None:
|
|
137
|
+
return "an unknown time"
|
|
138
|
+
hours = age.total_seconds() / 3600
|
|
139
|
+
if hours < 1:
|
|
140
|
+
return f"{int(age.total_seconds() // 60)} minutes"
|
|
141
|
+
if hours < 48:
|
|
142
|
+
return f"{hours:.0f} hours"
|
|
143
|
+
return f"{hours / 24:.1f} days"
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from dockerls.domain.entities.scan_result import ScanResult, ScanStatus
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from dockerls.domain.entities.image import DockerImage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CRITICAL_PENALTY = 20.0
|
|
15
|
+
HIGH_PENALTY = 5.0
|
|
16
|
+
MEDIUM_PENALTY = 1.0
|
|
17
|
+
EOL_PENALTY = 20.0
|
|
18
|
+
# Age is a staleness signal, not a vulnerability. Uncapped it grew by one
|
|
19
|
+
# point per year, so a 10-year-old image lost as much as two HIGH findings
|
|
20
|
+
# on age alone. Capped at 3, it can order equally-clean images without
|
|
21
|
+
# ever competing with measured severity.
|
|
22
|
+
MAX_AGE_PENALTY = 3.0
|
|
23
|
+
EXPLOITED_PENALTY = 10.0
|
|
24
|
+
# The step: a vulnerability at or above this probability draws a flat
|
|
25
|
+
# penalty. Kept because it is the part an operator can reason about ("EPSS
|
|
26
|
+
# over 0.5 costs 5 points"), and because removing it would silently change
|
|
27
|
+
# every score this tool has ever produced.
|
|
28
|
+
HIGH_EPSS_PENALTY = 5.0
|
|
29
|
+
HIGH_EPSS_THRESHOLD = 0.5
|
|
30
|
+
# The slope, added on top. The step alone made 0.97 and 0.51 cost exactly
|
|
31
|
+
# the same, and 0.49 cost nothing -- a cliff edge in the middle of a
|
|
32
|
+
# continuous measurement, where the difference between "half the time" and
|
|
33
|
+
# "almost certainly" is the whole point of having the number. The term is
|
|
34
|
+
# proportional to the probability itself and is capped well below a single
|
|
35
|
+
# HIGH finding, so it orders comparable images without ever competing with
|
|
36
|
+
# measured severity.
|
|
37
|
+
EPSS_SLOPE_PENALTY = 4.0
|
|
38
|
+
|
|
39
|
+
# Qualitative bonuses. Their total is deliberately held *below* the HIGH
|
|
40
|
+
# penalty: no amount of "official + minimal + signed + LTS + recent" may
|
|
41
|
+
# lift an image with an extra HIGH or CRITICAL above a cleaner one. They
|
|
42
|
+
# can outweigh a MEDIUM or two, which is intended -- a signed official
|
|
43
|
+
# distroless image with a couple of mediums is a reasonable pick over an
|
|
44
|
+
# unremarkable image with none.
|
|
45
|
+
OFFICIAL_BONUS = 1.0
|
|
46
|
+
MINIMAL_BASE_BONUS = 1.0
|
|
47
|
+
SIGNED_BONUS = 1.0
|
|
48
|
+
LTS_BONUS = 0.5
|
|
49
|
+
RECENT_BONUS = 0.5
|
|
50
|
+
MAX_BONUS = OFFICIAL_BONUS + MINIMAL_BASE_BONUS + SIGNED_BONUS + LTS_BONUS + RECENT_BONUS
|
|
51
|
+
|
|
52
|
+
# Scoring starts here rather than at 100 so a fully-decorated clean image
|
|
53
|
+
# lands exactly on 100 without being clamped. Clamping at the top was
|
|
54
|
+
# collapsing genuinely different images onto the same number: a clean
|
|
55
|
+
# image, a 1-HIGH image and a 5-MEDIUM image all read 100.0.
|
|
56
|
+
BASE_SCORE = 100.0 - MAX_BONUS
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SecurityScore:
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
image: DockerImage,
|
|
63
|
+
scan: ScanResult,
|
|
64
|
+
is_eol: bool = False,
|
|
65
|
+
is_lts: bool = False,
|
|
66
|
+
):
|
|
67
|
+
if scan.status not in (ScanStatus.OK, ScanStatus.PARTIAL):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Cannot score {image.full_reference}: scan status is "
|
|
70
|
+
f"{scan.status.value} ({scan.error_message or 'no details'})"
|
|
71
|
+
)
|
|
72
|
+
self._image = image
|
|
73
|
+
self._scan = scan
|
|
74
|
+
self._is_eol = is_eol
|
|
75
|
+
self._is_lts = is_lts
|
|
76
|
+
self._value = self._calculate()
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def value(self) -> float:
|
|
80
|
+
return self._value
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def penalty(self) -> float:
|
|
84
|
+
"""Everything measured about the image's vulnerabilities.
|
|
85
|
+
|
|
86
|
+
This alone decides the ordering between images with different
|
|
87
|
+
severity profiles -- the qualitative bonuses cannot overturn it for
|
|
88
|
+
HIGH or CRITICAL findings.
|
|
89
|
+
"""
|
|
90
|
+
penalty = (
|
|
91
|
+
self._scan.critical_count * CRITICAL_PENALTY
|
|
92
|
+
+ self._scan.high_count * HIGH_PENALTY
|
|
93
|
+
+ self._scan.medium_count * MEDIUM_PENALTY
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# CISA KEV / EPSS threat-intel signal: a vulnerability with a
|
|
97
|
+
# confirmed real-world exploit (or a high predicted exploitation
|
|
98
|
+
# probability) is materially worse than an unweighted CVSS count
|
|
99
|
+
# suggests, so it draws an extra penalty on top of the base
|
|
100
|
+
# severity penalties above.
|
|
101
|
+
penalty += EXPLOITED_PENALTY * sum(1 for v in self._scan.vulnerabilities if v.exploit_known)
|
|
102
|
+
penalty += HIGH_EPSS_PENALTY * sum(
|
|
103
|
+
1 for v in self._scan.vulnerabilities if v.epss_score >= HIGH_EPSS_THRESHOLD
|
|
104
|
+
)
|
|
105
|
+
# Continuous term, over the same findings as the step. No
|
|
106
|
+
# `epss_known` check is needed here and adding one would be wrong:
|
|
107
|
+
# the field defaults to 0.0, so a *non-zero* score is itself proof
|
|
108
|
+
# that a lookup returned it, and a zero contributes nothing either
|
|
109
|
+
# way. `epss_known` earns its keep in the reporting layer, where the
|
|
110
|
+
# difference between "scored at zero" and "never looked up" is a
|
|
111
|
+
# statement rather than a number.
|
|
112
|
+
penalty += EPSS_SLOPE_PENALTY * sum(v.epss_score for v in self._scan.vulnerabilities)
|
|
113
|
+
|
|
114
|
+
if self._is_eol:
|
|
115
|
+
penalty += EOL_PENALTY
|
|
116
|
+
# Age only moves the score when the source actually reported a
|
|
117
|
+
# publish date. Registries that list tag names only (Chainguard,
|
|
118
|
+
# most OCI catalogues) would otherwise be charged the maximum age
|
|
119
|
+
# penalty and denied the recency bonus for missing metadata.
|
|
120
|
+
if self._image.age_known:
|
|
121
|
+
penalty += min(self._image.age_days / 365.0, MAX_AGE_PENALTY)
|
|
122
|
+
return penalty
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def bonus(self) -> float:
|
|
126
|
+
"""Qualitative signals, capped below a single HIGH finding."""
|
|
127
|
+
bonus = 0.0
|
|
128
|
+
if self._image.is_official:
|
|
129
|
+
bonus += OFFICIAL_BONUS
|
|
130
|
+
# Distroless, hardened-vendor (Chainguard/Wolfi/Bitnami), and Alpine
|
|
131
|
+
# are all "minimal base" signals; an image matching more than one
|
|
132
|
+
# must not be double-counted.
|
|
133
|
+
if self._image.is_distroless or self._image.is_hardened_source or self._image.is_alpine:
|
|
134
|
+
bonus += MINIMAL_BASE_BONUS
|
|
135
|
+
if self._image.is_signed:
|
|
136
|
+
bonus += SIGNED_BONUS
|
|
137
|
+
if self._is_lts:
|
|
138
|
+
bonus += LTS_BONUS
|
|
139
|
+
if self._image.age_known and self._image.recently_updated:
|
|
140
|
+
bonus += RECENT_BONUS
|
|
141
|
+
return bonus
|
|
142
|
+
|
|
143
|
+
def _calculate(self) -> float:
|
|
144
|
+
# No "zero vulnerabilities" bonus: zero findings already means zero
|
|
145
|
+
# penalty, so rewarding it again double-counted the same fact and
|
|
146
|
+
# was part of what pushed clean images into the clamp.
|
|
147
|
+
score = BASE_SCORE - self.penalty + self.bonus
|
|
148
|
+
if not math.isfinite(score):
|
|
149
|
+
# The clamp below cannot catch this: every comparison against
|
|
150
|
+
# NaN is False, so `max(0.0, min(100.0, nan))` answers 100.0 --
|
|
151
|
+
# the arithmetic failing produced the highest possible score.
|
|
152
|
+
# Whatever went wrong, it is not evidence that the image is
|
|
153
|
+
# clean, so the score collapses to the bottom and the reason is
|
|
154
|
+
# loud.
|
|
155
|
+
logger.error(
|
|
156
|
+
f"Security score for {self._image.name}:{self._image.tag} was not a finite "
|
|
157
|
+
f"number (penalty={self.penalty}, bonus={self.bonus}); reporting 0.0"
|
|
158
|
+
)
|
|
159
|
+
return 0.0
|
|
160
|
+
return max(0.0, min(100.0, round(score, 1)))
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from dockerls.domain.entities.scan_result import ScanResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Tier(StrEnum):
|
|
11
|
+
A = "A"
|
|
12
|
+
B = "B"
|
|
13
|
+
C = "C"
|
|
14
|
+
D = "D"
|
|
15
|
+
E = "E"
|
|
16
|
+
F = "F"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
#: Do melhor para o pior. A ordem é explícita porque a trava de CRITICAL
|
|
20
|
+
#: precisa comparar tiers, e comparar letras alfabeticamente só funciona por
|
|
21
|
+
#: coincidência -- deixaria de funcionar no instante em que a escala mudar.
|
|
22
|
+
TIER_ORDER: tuple[Tier, ...] = (Tier.A, Tier.B, Tier.C, Tier.D, Tier.E, Tier.F)
|
|
23
|
+
|
|
24
|
+
#: Piso de score de cada faixa, do topo para a base. Cobre toda a faixa
|
|
25
|
+
#: 0-100: antes a escala parava em C, então um score 0.0 com 6 CRITICAL e 170
|
|
26
|
+
#: vulnerabilidades recebia exatamente o mesmo tier de uma imagem 36 pontos
|
|
27
|
+
#: melhor -- a nota deixava de discriminar justamente onde mais importava.
|
|
28
|
+
TIER_BANDS: tuple[tuple[float, Tier], ...] = (
|
|
29
|
+
(90.0, Tier.A),
|
|
30
|
+
(75.0, Tier.B),
|
|
31
|
+
(60.0, Tier.C),
|
|
32
|
+
(40.0, Tier.D),
|
|
33
|
+
(20.0, Tier.E),
|
|
34
|
+
)
|
|
35
|
+
LOWEST_TIER = Tier.F
|
|
36
|
+
|
|
37
|
+
#: Melhor tier que uma imagem com CRITICAL sem correção disponível pode
|
|
38
|
+
#: alcançar. Um CRITICAL que nem dá para consertar não é compensável por
|
|
39
|
+
#: nenhum outro sinal: por mais alto que o score fique, a imagem não passa de
|
|
40
|
+
#: "exige revisão humana".
|
|
41
|
+
UNFIXABLE_CRITICAL_CEILING = Tier.C
|
|
42
|
+
|
|
43
|
+
#: Tiers que uma imagem precisa alcançar para ser considerada pronta para
|
|
44
|
+
#: produção, alinhado com `DockerfileAnalysis.is_production_ready`.
|
|
45
|
+
PRODUCTION_READY_TIERS = (Tier.A, Tier.B)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def tier_for_score(score: float) -> Tier:
|
|
49
|
+
for floor, tier in TIER_BANDS:
|
|
50
|
+
if score >= floor:
|
|
51
|
+
return tier
|
|
52
|
+
return LOWEST_TIER
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SecurityTier:
|
|
56
|
+
"""Traduz o score de segurança numa nota de A a F.
|
|
57
|
+
|
|
58
|
+
A escala completa, documentada também no README:
|
|
59
|
+
|
|
60
|
+
| Tier | Score | Leitura |
|
|
61
|
+
|------|---------|--------------------------------------------|
|
|
62
|
+
| A | 90-100 | pronta para produção |
|
|
63
|
+
| B | 75-89 | pronta para produção |
|
|
64
|
+
| C | 60-74 | condicional: exige revisão humana |
|
|
65
|
+
| D | 40-59 | não pronta para produção |
|
|
66
|
+
| E | 20-39 | não pronta para produção |
|
|
67
|
+
| F | 0-19 | não usar |
|
|
68
|
+
|
|
69
|
+
Sobre a trava: uma imagem com CRITICAL **sem correção disponível** nunca
|
|
70
|
+
passa de C, por mais alto que o score tenha ficado. E uma base EOL nunca é
|
|
71
|
+
production-ready, independentemente do tier -- ela vai parar de receber
|
|
72
|
+
correções de segurança de qualquer forma.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
ADVICE = {
|
|
76
|
+
Tier.C: "conditional -- requires human review before production use",
|
|
77
|
+
Tier.D: "not production ready",
|
|
78
|
+
Tier.E: "not production ready",
|
|
79
|
+
Tier.F: "not production ready -- do not deploy",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def __init__(self, scan: ScanResult, security_score: float, is_eol: bool = False):
|
|
83
|
+
self._scan = scan
|
|
84
|
+
self._score = security_score
|
|
85
|
+
self._is_eol = is_eol
|
|
86
|
+
self._tier = self._classify()
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def tier(self) -> Tier:
|
|
90
|
+
return self._tier
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def unfixable_critical_count(self) -> int:
|
|
94
|
+
return self._scan.critical_count - self._scan.fixable_critical_count
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def production_ready(self) -> bool:
|
|
98
|
+
"""Whether the *tier* alone would permit production use.
|
|
99
|
+
|
|
100
|
+
**This is not the verdict.** It answers a narrower question than it
|
|
101
|
+
appears to: it can see the score and the EOL flag, and nothing else
|
|
102
|
+
-- not whether the scan completed, not whether two scanners agreed,
|
|
103
|
+
not whether anything was verified. Read as the verdict, it reported
|
|
104
|
+
a PARTIAL scan with no findings as production ready on the same
|
|
105
|
+
analysis that reported UNVERIFIED.
|
|
106
|
+
|
|
107
|
+
`ProductionReadiness` in `domain/value_objects/production_readiness.py`
|
|
108
|
+
is the policy that decides, and `ImageAnalysis.production_ready` is
|
|
109
|
+
written only by it. This property survives as the tier-level rule
|
|
110
|
+
that policy consumes.
|
|
111
|
+
"""
|
|
112
|
+
# An EOL base is never production-ready, regardless of its
|
|
113
|
+
# vulnerability tier -- it will stop receiving security patches.
|
|
114
|
+
if self._is_eol:
|
|
115
|
+
return False
|
|
116
|
+
return self._tier in PRODUCTION_READY_TIERS
|
|
117
|
+
|
|
118
|
+
def _classify(self) -> Tier:
|
|
119
|
+
tier = tier_for_score(self._score)
|
|
120
|
+
if self.unfixable_critical_count > 0:
|
|
121
|
+
tier = max(tier, UNFIXABLE_CRITICAL_CEILING, key=TIER_ORDER.index)
|
|
122
|
+
return tier
|