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,133 @@
|
|
|
1
|
+
"""Perguntar ao registry o que ele conta sobre uma imagem publicada.
|
|
2
|
+
|
|
3
|
+
A metade pura está em `domain/value_objects/registry_audit.py`. Aqui mora o
|
|
4
|
+
que ela não pode fazer: falar com o registry.
|
|
5
|
+
|
|
6
|
+
Cada checagem é uma pergunta com resposta de três valores, e a terceira --
|
|
7
|
+
"não deu para perguntar" -- é a razão de este módulo existir em vez de uma
|
|
8
|
+
sequência de `if`. Um registry que não responde sobre a assinatura produziria,
|
|
9
|
+
num código com booleano, exatamente a mesma saída de um registry que respondeu
|
|
10
|
+
"não há assinatura". As duas frases levam a decisões opostas.
|
|
11
|
+
|
|
12
|
+
A assinatura e a atestação são procuradas onde o cosign as publica: como tags
|
|
13
|
+
derivadas do digest (`sha256-<hex>.sig` e `sha256-<hex>.att`). É uma convenção
|
|
14
|
+
do sigstore, não do OCI -- então a ausência delas significa "não está assinado
|
|
15
|
+
*com cosign nesse esquema*", e a explicação diz isso em vez de afirmar que
|
|
16
|
+
ninguém assinou nada.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from loguru import logger
|
|
24
|
+
|
|
25
|
+
from dockerls.domain.entities.image import DockerImage
|
|
26
|
+
from dockerls.domain.value_objects.registry_audit import (
|
|
27
|
+
AuditCheck,
|
|
28
|
+
AuditFinding,
|
|
29
|
+
RegistryAudit,
|
|
30
|
+
)
|
|
31
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from dockerls.application.services.tag_history_store import TagHistoryStore
|
|
35
|
+
from dockerls.integrations.registry.inspector import RegistryInspector
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RegistryAuditUseCase:
|
|
39
|
+
"""Apura o que o protocolo OCI revela sobre uma referência publicada."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, inspector: RegistryInspector, history: TagHistoryStore | None = None):
|
|
42
|
+
self._inspector = inspector
|
|
43
|
+
self._history = history
|
|
44
|
+
|
|
45
|
+
async def execute(self, reference: str) -> RegistryAudit:
|
|
46
|
+
name, tag, given_digest = _split(reference)
|
|
47
|
+
if not name:
|
|
48
|
+
return RegistryAudit(reference=reference)
|
|
49
|
+
|
|
50
|
+
digest = given_digest or await self._digest(name, tag)
|
|
51
|
+
findings = [
|
|
52
|
+
AuditFinding(
|
|
53
|
+
check=AuditCheck.RESOLVABLE,
|
|
54
|
+
state=Tristate.of(bool(digest)),
|
|
55
|
+
detail=digest,
|
|
56
|
+
),
|
|
57
|
+
AuditFinding(
|
|
58
|
+
check=AuditCheck.PINNED_REFERENCE,
|
|
59
|
+
state=Tristate.of(bool(given_digest)),
|
|
60
|
+
),
|
|
61
|
+
# O inspector fala com o registry sem nenhuma credencial. Ter
|
|
62
|
+
# obtido resposta *é* a medição do acesso anônimo -- não uma
|
|
63
|
+
# inferência sobre ela.
|
|
64
|
+
AuditFinding(
|
|
65
|
+
check=AuditCheck.PUBLICLY_READABLE,
|
|
66
|
+
state=Tristate.of(bool(digest)) if not given_digest else Tristate.UNKNOWN,
|
|
67
|
+
),
|
|
68
|
+
await self._tag_stability(name, tag, given_digest=bool(given_digest)),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
if digest:
|
|
72
|
+
findings.append(await self._cosign_artifact(name, digest, AuditCheck.SIGNATURE_PRESENT))
|
|
73
|
+
findings.append(
|
|
74
|
+
await self._cosign_artifact(name, digest, AuditCheck.ATTESTATION_PRESENT)
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
# Sem digest não há o que procurar: reportar `UNKNOWN` é a única
|
|
78
|
+
# resposta honesta, e é diferente de "não há assinatura".
|
|
79
|
+
findings.append(
|
|
80
|
+
AuditFinding(check=AuditCheck.SIGNATURE_PRESENT, state=Tristate.UNKNOWN)
|
|
81
|
+
)
|
|
82
|
+
findings.append(
|
|
83
|
+
AuditFinding(check=AuditCheck.ATTESTATION_PRESENT, state=Tristate.UNKNOWN)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return RegistryAudit(reference=reference, digest=digest, findings=tuple(findings))
|
|
87
|
+
|
|
88
|
+
async def _digest(self, name: str, tag: str) -> str:
|
|
89
|
+
try:
|
|
90
|
+
return await self._inspector.resolve_digest(DockerImage(name=name, tag=tag))
|
|
91
|
+
except Exception as e: # pragma: no cover - rede é o caminho instável
|
|
92
|
+
logger.debug(f"Could not resolve {name}:{tag}: {e}")
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
async def _tag_stability(self, name: str, tag: str, *, given_digest: bool) -> AuditFinding:
|
|
96
|
+
"""Se a tag já mudou de digest -- a evidência *medida* de mutabilidade.
|
|
97
|
+
|
|
98
|
+
A configuração de imutabilidade do registry é uma declaração; o
|
|
99
|
+
histórico é uma observação. Quando as duas discordam, a observação é
|
|
100
|
+
que descreve o que aconteceu de fato.
|
|
101
|
+
"""
|
|
102
|
+
if given_digest or self._history is None:
|
|
103
|
+
return AuditFinding(check=AuditCheck.TAG_STABLE, state=Tristate.UNKNOWN)
|
|
104
|
+
history = await self._history.get(f"{name}:{tag}")
|
|
105
|
+
if history.is_empty:
|
|
106
|
+
return AuditFinding(check=AuditCheck.TAG_STABLE, state=Tristate.UNKNOWN)
|
|
107
|
+
return AuditFinding(
|
|
108
|
+
check=AuditCheck.TAG_STABLE,
|
|
109
|
+
state=Tristate.of(history.moves == 0),
|
|
110
|
+
detail=history.explain(),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def _cosign_artifact(self, name: str, digest: str, check: AuditCheck) -> AuditFinding:
|
|
114
|
+
"""Procura a tag derivada onde o cosign publica assinatura/atestação."""
|
|
115
|
+
suffix = ".sig" if check is AuditCheck.SIGNATURE_PRESENT else ".att"
|
|
116
|
+
derived = f"{digest.replace(':', '-')}{suffix}"
|
|
117
|
+
try:
|
|
118
|
+
found = await self._inspector.resolve_digest(DockerImage(name=name, tag=derived))
|
|
119
|
+
except Exception as e: # pragma: no cover - rede é o caminho instável
|
|
120
|
+
logger.debug(f"Could not query {derived}: {e}")
|
|
121
|
+
return AuditFinding(check=check, state=Tristate.UNKNOWN)
|
|
122
|
+
# "" aqui é resposta: o registry falou e não tem essa tag. O caso de
|
|
123
|
+
# não conseguir falar cai no `except` acima, e vira UNKNOWN.
|
|
124
|
+
return AuditFinding(check=check, state=Tristate.of(bool(found)), detail=found)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _split(reference: str) -> tuple[str, str, str]:
|
|
128
|
+
"""`nome`, `tag`, `digest` -- com os vazios explícitos."""
|
|
129
|
+
remainder, _, digest = reference.partition("@")
|
|
130
|
+
head, slash, last = remainder.rpartition("/")
|
|
131
|
+
name_part, _, tag = last.partition(":")
|
|
132
|
+
name = f"{head}{slash}{name_part}"
|
|
133
|
+
return name, tag or "latest", digest
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from dockerls.application.services.teardown import close_quietly, sources_of
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from dockerls.domain.entities.image import DockerImage
|
|
9
|
+
from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SearchImagesUseCase:
|
|
13
|
+
def __init__(self, repository: ImageRepositoryInterface):
|
|
14
|
+
self._repository = repository
|
|
15
|
+
|
|
16
|
+
async def execute(self, image_name: str, limit: int = 100) -> list[DockerImage]:
|
|
17
|
+
try:
|
|
18
|
+
return await self._repository.search_tags(image_name, limit=limit)
|
|
19
|
+
finally:
|
|
20
|
+
# The repository keeps one connection pool alive for the whole
|
|
21
|
+
# run so requests can reuse it, which makes handing it back the
|
|
22
|
+
# caller's job rather than a context manager's.
|
|
23
|
+
await close_quietly(*sources_of(self._repository))
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Perguntar ao registry o que cada `FROM` aponta hoje, e propor a correção.
|
|
2
|
+
|
|
3
|
+
A metade pura do trabalho está em `domain/value_objects/base_upgrade.py`. Aqui
|
|
4
|
+
mora a única coisa que ela não pode fazer: falar com o registry para descobrir
|
|
5
|
+
o digest que uma tag aponta *neste momento*.
|
|
6
|
+
|
|
7
|
+
A comparação entre esse digest e o que está escrito no Dockerfile é o que
|
|
8
|
+
detecta a base que apodreceu -- o caso que não avisa ninguém. Foi assim que
|
|
9
|
+
uma base de meados de 2024 continuou sendo construída nesta imagem por meses,
|
|
10
|
+
carregando duas CVEs CRITICAL do `libexpat1` que já tinham correção publicada.
|
|
11
|
+
O Dockerfile estava "corretamente" fixado por digest o tempo todo; fixar sem
|
|
12
|
+
nunca reavaliar é como trancar a porta e jogar fora o calendário.
|
|
13
|
+
|
|
14
|
+
Uma base que o registry não soube responder fica `UNRESOLVED`, nunca
|
|
15
|
+
"atualizada": é ausência de resposta, e o resto desta ferramenta é construído
|
|
16
|
+
sobre a recusa de transformar isso em afirmação.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from loguru import logger
|
|
26
|
+
|
|
27
|
+
from dockerls.domain.entities.image import DockerImage
|
|
28
|
+
from dockerls.domain.value_objects.base_upgrade import (
|
|
29
|
+
BaseFinding,
|
|
30
|
+
BaseStatus,
|
|
31
|
+
DeclaredBase,
|
|
32
|
+
classify,
|
|
33
|
+
parse_bases,
|
|
34
|
+
rewrite,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from dockerls.application.services.tag_history_store import TagHistoryStore
|
|
39
|
+
from dockerls.domain.value_objects.tag_history import TagHistory
|
|
40
|
+
from dockerls.integrations.registry.inspector import RegistryInspector
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class UpgradeBaseResult:
|
|
45
|
+
"""O que foi encontrado e, quando pedido, o que foi aplicado."""
|
|
46
|
+
|
|
47
|
+
dockerfile: str
|
|
48
|
+
findings: list[BaseFinding] = field(default_factory=list)
|
|
49
|
+
#: Substituições escritas em disco. Zero em `--dry-run`, e zero também
|
|
50
|
+
#: quando não havia nada a mudar.
|
|
51
|
+
applied: int = 0
|
|
52
|
+
#: Conteúdo resultante, escrito ou não. Serve para mostrar o diff.
|
|
53
|
+
updated_content: str = ""
|
|
54
|
+
error: str = ""
|
|
55
|
+
#: Histórico de digests por referência (`nome:tag`), quando há onde
|
|
56
|
+
#: guardá-lo. É o que transforma "esta base mudou" em "esta base muda com
|
|
57
|
+
#: esta frequência" -- duas frases que pedem decisões diferentes.
|
|
58
|
+
histories: dict[str, TagHistory] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def outdated(self) -> list[BaseFinding]:
|
|
62
|
+
return [f for f in self.findings if f.status is BaseStatus.PINNED_STALE]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def unpinned(self) -> list[BaseFinding]:
|
|
66
|
+
return [f for f in self.findings if f.status is BaseStatus.UNPINNED]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def unresolved(self) -> list[BaseFinding]:
|
|
70
|
+
return [f for f in self.findings if f.status is BaseStatus.UNRESOLVED]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def needs_action(self) -> bool:
|
|
74
|
+
return any(f.status.needs_action for f in self.findings)
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict[str, object]:
|
|
77
|
+
return {
|
|
78
|
+
"dockerfile": self.dockerfile,
|
|
79
|
+
"applied": self.applied,
|
|
80
|
+
"bases": [
|
|
81
|
+
{
|
|
82
|
+
"line": f.base.line,
|
|
83
|
+
"reference": f.base.reference,
|
|
84
|
+
"stage": f.base.stage,
|
|
85
|
+
"status": str(f.status),
|
|
86
|
+
"explanation": f.explain(),
|
|
87
|
+
"current_digest": f.current_digest,
|
|
88
|
+
"proposed": f.proposed_reference,
|
|
89
|
+
"digest_arg": f.base.digest_arg,
|
|
90
|
+
"history": self._history_dict(f.base),
|
|
91
|
+
}
|
|
92
|
+
for f in self.findings
|
|
93
|
+
],
|
|
94
|
+
"error": self.error,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def history_for(self, base: DeclaredBase) -> TagHistory | None:
|
|
98
|
+
return self.histories.get(_history_key(base))
|
|
99
|
+
|
|
100
|
+
def _history_dict(self, base: DeclaredBase) -> dict[str, object] | None:
|
|
101
|
+
history = self.history_for(base)
|
|
102
|
+
return history.to_dict() if history and not history.is_empty else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _history_key(base: DeclaredBase) -> str:
|
|
106
|
+
return f"{base.name}:{base.tag or 'latest'}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class UpgradeBaseUseCase:
|
|
110
|
+
"""Confere as bases de um Dockerfile contra o registry."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, inspector: RegistryInspector, history: TagHistoryStore | None = None):
|
|
113
|
+
self._inspector = inspector
|
|
114
|
+
self._history = history
|
|
115
|
+
|
|
116
|
+
async def execute(
|
|
117
|
+
self, dockerfile_path: str | Path, *, apply: bool = True
|
|
118
|
+
) -> UpgradeBaseResult:
|
|
119
|
+
path = Path(dockerfile_path)
|
|
120
|
+
if path.is_dir():
|
|
121
|
+
path = path / "Dockerfile"
|
|
122
|
+
if not path.is_file():
|
|
123
|
+
return UpgradeBaseResult(dockerfile=str(path), error=f"Dockerfile not found at {path}")
|
|
124
|
+
|
|
125
|
+
content = path.read_text(encoding="utf-8")
|
|
126
|
+
bases = parse_bases(content)
|
|
127
|
+
if not bases:
|
|
128
|
+
return UpgradeBaseResult(dockerfile=str(path), error="no FROM instruction found")
|
|
129
|
+
|
|
130
|
+
findings: list[BaseFinding] = []
|
|
131
|
+
histories: dict[str, TagHistory] = {}
|
|
132
|
+
for base in bases:
|
|
133
|
+
digest = await self._current_digest(base)
|
|
134
|
+
findings.append(classify(base, digest))
|
|
135
|
+
if self._history is not None and digest:
|
|
136
|
+
key = _history_key(base)
|
|
137
|
+
histories[key] = await self._history.observe(key, digest)
|
|
138
|
+
updated, would_apply = rewrite(content, findings)
|
|
139
|
+
|
|
140
|
+
result = UpgradeBaseResult(
|
|
141
|
+
dockerfile=str(path),
|
|
142
|
+
findings=findings,
|
|
143
|
+
updated_content=updated,
|
|
144
|
+
histories=histories,
|
|
145
|
+
)
|
|
146
|
+
if apply and would_apply and updated != content:
|
|
147
|
+
try:
|
|
148
|
+
path.write_text(updated, encoding="utf-8")
|
|
149
|
+
except OSError as e:
|
|
150
|
+
# Não conseguir escrever não invalida o diagnóstico: o
|
|
151
|
+
# relatório continua válido e a pessoa aplica à mão.
|
|
152
|
+
result.error = f"could not write {path}: {e}"
|
|
153
|
+
return result
|
|
154
|
+
result.applied = would_apply
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
async def _current_digest(self, base: DeclaredBase) -> str:
|
|
158
|
+
"""O digest que a tag aponta agora, ou "" quando não deu para perguntar."""
|
|
159
|
+
name = getattr(base, "name", "")
|
|
160
|
+
tag = getattr(base, "tag", "") or "latest"
|
|
161
|
+
if not name:
|
|
162
|
+
return ""
|
|
163
|
+
try:
|
|
164
|
+
return await self._inspector.resolve_digest(DockerImage(name=name, tag=tag))
|
|
165
|
+
except Exception as e: # pragma: no cover - rede é o caminho instável
|
|
166
|
+
logger.debug(f"Could not resolve the digest of {name}:{tag}: {e}")
|
|
167
|
+
return ""
|
|
File without changes
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Any, NamedTuple, cast
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import CursorResult, delete, func, select
|
|
9
|
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
10
|
+
|
|
11
|
+
from dockerls.domain.interfaces.cache_store import CacheStoreInterface
|
|
12
|
+
from dockerls.infrastructure.database.models import CacheEntry, create_db_engine
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from sqlalchemy.orm import Session
|
|
18
|
+
|
|
19
|
+
# Bump this when the shape of cached payloads changes so stale entries from
|
|
20
|
+
# an older schema are treated as misses instead of crashing on load.
|
|
21
|
+
# v2: ImageAnalysis gained verification metadata (scan evidence paths, Hub
|
|
22
|
+
# tag state, scanner divergence) and ScanResult gained `evidence_path`.
|
|
23
|
+
# v3: ImageAnalysis gained the assessment fields (hardening facts, hardening
|
|
24
|
+
# and attack-surface reports, confidence, why/trade-offs) and ScanResult
|
|
25
|
+
# gained the scanner-reported base distribution. A v2 row would still
|
|
26
|
+
# *validate* against the new model -- pydantic would fill the missing fields
|
|
27
|
+
# with their defaults -- and that is exactly the problem: the defaults are
|
|
28
|
+
# "nothing determined" and `UNVERIFIED`, so a stale row would present an
|
|
29
|
+
# image as uninspected rather than as unscanned. Orphaning the old rows
|
|
30
|
+
# costs one cold run and removes the ambiguity entirely.
|
|
31
|
+
# v4: ImageAnalysis gained the readiness verdict (production_ready is now
|
|
32
|
+
# written by the central policy, and its default flipped to False), the
|
|
33
|
+
# three-valued EOL status, and the cross-validation outcome; Vulnerability
|
|
34
|
+
# gained the three-valued KEV status and the EPSS provenance fields. A v3 row
|
|
35
|
+
# would validate and fill all of them with defaults -- which read as
|
|
36
|
+
# "nothing determined" and would present a cached image as uninspected
|
|
37
|
+
# rather than as measured.
|
|
38
|
+
CACHE_SCHEMA_VERSION = "v4"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CacheStats(NamedTuple):
|
|
42
|
+
"""What `dockerls cache stats` reports.
|
|
43
|
+
|
|
44
|
+
Entries expire lazily -- a stale row is dropped when it is next read --
|
|
45
|
+
so `expired` is the amount `dockerls cache cleanup` would reclaim right
|
|
46
|
+
now, and the gap between it and `total` is what the cache is holding on
|
|
47
|
+
to for nothing.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
total: int
|
|
51
|
+
expired: int
|
|
52
|
+
size_bytes: int
|
|
53
|
+
path: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SQLiteCache(CacheStoreInterface):
|
|
57
|
+
def __init__(self, db_path: Path):
|
|
58
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
self._db_path = db_path
|
|
60
|
+
self._engine, self._session_factory = create_db_engine(str(db_path))
|
|
61
|
+
|
|
62
|
+
def _session(self) -> Session:
|
|
63
|
+
return self._session_factory()
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
"""Dispose the SQLAlchemy engine and release its pooled connection.
|
|
67
|
+
|
|
68
|
+
Nothing here ever called this: the engine opened in `__init__` lived
|
|
69
|
+
until the process exited, which is why `pytest` -- which keeps the
|
|
70
|
+
interpreter running across thousands of these -- reported unclosed
|
|
71
|
+
`sqlite3.Connection` objects (`ResourceWarning`) from tests that
|
|
72
|
+
never mention caching at all. A short-lived CLI invocation masked
|
|
73
|
+
the same leak by exiting anyway.
|
|
74
|
+
"""
|
|
75
|
+
self._engine.dispose()
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> SQLiteCache:
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
81
|
+
self.close()
|
|
82
|
+
|
|
83
|
+
def _versioned_key(self, key: str) -> str:
|
|
84
|
+
return f"{CACHE_SCHEMA_VERSION}:{key}"
|
|
85
|
+
|
|
86
|
+
async def get(self, key: str) -> Any | None:
|
|
87
|
+
return await asyncio.to_thread(self._get_sync, key)
|
|
88
|
+
|
|
89
|
+
def _get_sync(self, key: str) -> Any | None:
|
|
90
|
+
vkey = self._versioned_key(key)
|
|
91
|
+
with self._session() as session:
|
|
92
|
+
stmt = select(CacheEntry).where(CacheEntry.key == vkey)
|
|
93
|
+
entry = session.execute(stmt).scalar_one_or_none()
|
|
94
|
+
if entry is None:
|
|
95
|
+
return None
|
|
96
|
+
if entry.expires_at < time.time():
|
|
97
|
+
session.delete(entry)
|
|
98
|
+
session.commit()
|
|
99
|
+
return None
|
|
100
|
+
return json.loads(entry.value)
|
|
101
|
+
|
|
102
|
+
async def set(self, key: str, value: Any, ttl_seconds: int = 86400) -> None:
|
|
103
|
+
await asyncio.to_thread(self._set_sync, key, value, ttl_seconds)
|
|
104
|
+
|
|
105
|
+
def _set_sync(self, key: str, value: Any, ttl_seconds: int) -> None:
|
|
106
|
+
vkey = self._versioned_key(key)
|
|
107
|
+
serialized = json.dumps(value, default=str)
|
|
108
|
+
expires_at = time.time() + ttl_seconds
|
|
109
|
+
# Writes run on a thread pool (`asyncio.to_thread`) and `recommend`
|
|
110
|
+
# issues them concurrently, so select-then-insert had a real window:
|
|
111
|
+
# two threads could both miss and then both INSERT the same unique
|
|
112
|
+
# key. A single atomic upsert closes it -- SQLite's ON CONFLICT does
|
|
113
|
+
# the check and the write in one statement.
|
|
114
|
+
stmt = (
|
|
115
|
+
sqlite_insert(CacheEntry)
|
|
116
|
+
.values(key=vkey, value=serialized, expires_at=expires_at)
|
|
117
|
+
.on_conflict_do_update(
|
|
118
|
+
index_elements=[CacheEntry.key],
|
|
119
|
+
set_={"value": serialized, "expires_at": expires_at},
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
with self._session() as session:
|
|
123
|
+
session.execute(stmt)
|
|
124
|
+
session.commit()
|
|
125
|
+
|
|
126
|
+
async def delete(self, key: str) -> None:
|
|
127
|
+
await asyncio.to_thread(self._delete_sync, key)
|
|
128
|
+
|
|
129
|
+
def _delete_sync(self, key: str) -> None:
|
|
130
|
+
vkey = self._versioned_key(key)
|
|
131
|
+
with self._session() as session:
|
|
132
|
+
session.execute(delete(CacheEntry).where(CacheEntry.key == vkey))
|
|
133
|
+
session.commit()
|
|
134
|
+
|
|
135
|
+
async def clear(self) -> None:
|
|
136
|
+
await asyncio.to_thread(self._clear_sync)
|
|
137
|
+
|
|
138
|
+
def _clear_sync(self) -> None:
|
|
139
|
+
with self._session() as session:
|
|
140
|
+
session.execute(delete(CacheEntry))
|
|
141
|
+
session.commit()
|
|
142
|
+
|
|
143
|
+
async def cleanup_expired(self) -> int:
|
|
144
|
+
return await asyncio.to_thread(self._cleanup_expired_sync)
|
|
145
|
+
|
|
146
|
+
def _cleanup_expired_sync(self) -> int:
|
|
147
|
+
with self._session() as session:
|
|
148
|
+
stmt = delete(CacheEntry).where(CacheEntry.expires_at < time.time())
|
|
149
|
+
result = cast("CursorResult[Any]", session.execute(stmt))
|
|
150
|
+
session.commit()
|
|
151
|
+
return result.rowcount
|
|
152
|
+
|
|
153
|
+
async def stats(self) -> CacheStats:
|
|
154
|
+
return await asyncio.to_thread(self._stats_sync)
|
|
155
|
+
|
|
156
|
+
def _stats_sync(self) -> CacheStats:
|
|
157
|
+
now = time.time()
|
|
158
|
+
with self._session() as session:
|
|
159
|
+
total = session.execute(select(func.count()).select_from(CacheEntry)).scalar_one()
|
|
160
|
+
expired = session.execute(
|
|
161
|
+
select(func.count()).select_from(CacheEntry).where(CacheEntry.expires_at < now)
|
|
162
|
+
).scalar_one()
|
|
163
|
+
return CacheStats(
|
|
164
|
+
total=int(total),
|
|
165
|
+
expired=int(expired),
|
|
166
|
+
size_bytes=self._size_on_disk(),
|
|
167
|
+
path=str(self._db_path),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _size_on_disk(self) -> int:
|
|
171
|
+
"""Bytes the cache occupies, including the WAL sidecar.
|
|
172
|
+
|
|
173
|
+
The write-ahead log holds committed data that has not been
|
|
174
|
+
checkpointed back into the main file yet, so reporting only the
|
|
175
|
+
`.db` would understate the footprint -- sometimes by most of it.
|
|
176
|
+
"""
|
|
177
|
+
total = 0
|
|
178
|
+
for suffix in ("", "-wal", "-shm"):
|
|
179
|
+
part = self._db_path.with_name(self._db_path.name + suffix)
|
|
180
|
+
try:
|
|
181
|
+
total += part.stat().st_size
|
|
182
|
+
except OSError:
|
|
183
|
+
continue
|
|
184
|
+
return total
|
dockerls/cli/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Cross-referencing a `build` run against an earlier `analyze-dockerfile`.
|
|
2
|
+
|
|
3
|
+
`analyze-dockerfile` and `build` both validate the same Dockerfile -- build
|
|
4
|
+
always does it as its first step -- but the two commands ran as two
|
|
5
|
+
separate documents, with no way to say whether what `analyze-dockerfile`
|
|
6
|
+
found earlier was actually fixed by the time `build` ran. This closes that
|
|
7
|
+
gap for whoever passes `analyze-dockerfile --output <file>` into `build
|
|
8
|
+
--compare-to-analysis <file>`: nothing is inferred that either command did
|
|
9
|
+
not already measure, this only compares the two results check by check.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from dockerls.domain.entities.dockerfile_analysis import DockerfileValidationResult
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaselineLoadError(ValueError):
|
|
24
|
+
"""The file at `--compare-to-analysis` is not a usable analyze-dockerfile report."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class AnalysisComparison:
|
|
29
|
+
"""What changed, check by check, between the earlier report and now."""
|
|
30
|
+
|
|
31
|
+
#: Checks that were FAIL or WARN in the baseline and still are now.
|
|
32
|
+
still_present: tuple[str, ...] = ()
|
|
33
|
+
#: Checks that were FAIL or WARN in the baseline and are PASS now.
|
|
34
|
+
resolved: tuple[str, ...] = ()
|
|
35
|
+
#: Checks that are FAIL or WARN now but were not flagged in the baseline.
|
|
36
|
+
newly_introduced: tuple[str, ...] = ()
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def baseline_total(self) -> int:
|
|
40
|
+
return len(self.still_present) + len(self.resolved)
|
|
41
|
+
|
|
42
|
+
def model_dump(self) -> dict[str, object]:
|
|
43
|
+
return {
|
|
44
|
+
"still_present": list(self.still_present),
|
|
45
|
+
"resolved": list(self.resolved),
|
|
46
|
+
"newly_introduced": list(self.newly_introduced),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_baseline_findings(path: str | Path) -> dict[str, str]:
|
|
51
|
+
"""The FAIL/WARN check names from a report `analyze-dockerfile --output`
|
|
52
|
+
wrote, as `{check_name: status}`.
|
|
53
|
+
|
|
54
|
+
Raises `BaselineLoadError` for anything that is not that document --
|
|
55
|
+
missing file, invalid JSON, or JSON that is valid but is not an
|
|
56
|
+
analyze-dockerfile report -- so the caller can report one clear cause
|
|
57
|
+
instead of a `KeyError` three lines deep.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
raw = Path(path).read_text(encoding="utf-8")
|
|
61
|
+
except OSError as e:
|
|
62
|
+
raise BaselineLoadError(f"could not read {path}: {e}") from e
|
|
63
|
+
try:
|
|
64
|
+
document = json.loads(raw)
|
|
65
|
+
except json.JSONDecodeError as e:
|
|
66
|
+
raise BaselineLoadError(f"{path} is not valid JSON: {e}") from e
|
|
67
|
+
|
|
68
|
+
validation = document.get("validation") if isinstance(document, dict) else None
|
|
69
|
+
if not isinstance(validation, dict):
|
|
70
|
+
raise BaselineLoadError(
|
|
71
|
+
f"{path} does not look like an analyze-dockerfile report (no 'validation' key)"
|
|
72
|
+
)
|
|
73
|
+
checks = validation.get("checks")
|
|
74
|
+
if not isinstance(checks, list):
|
|
75
|
+
raise BaselineLoadError(f"{path} has no 'validation.checks' list to compare against")
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
check["check"]: check["status"]
|
|
79
|
+
for check in checks
|
|
80
|
+
if isinstance(check, dict)
|
|
81
|
+
and check.get("status") in ("FAIL", "WARN")
|
|
82
|
+
and isinstance(check.get("check"), str)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def compare(baseline: dict[str, str], current: DockerfileValidationResult) -> AnalysisComparison:
|
|
87
|
+
"""Baseline FAIL/WARN checks against the checks in `current`."""
|
|
88
|
+
current_bad = {
|
|
89
|
+
c.check: c.status.value for c in current.checks if c.status.value in ("FAIL", "WARN")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
still_present = tuple(sorted(name for name in baseline if name in current_bad))
|
|
93
|
+
resolved = tuple(sorted(name for name in baseline if name not in current_bad))
|
|
94
|
+
newly_introduced = tuple(sorted(name for name in current_bad if name not in baseline))
|
|
95
|
+
|
|
96
|
+
return AnalysisComparison(
|
|
97
|
+
still_present=still_present, resolved=resolved, newly_introduced=newly_introduced
|
|
98
|
+
)
|