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,76 @@
|
|
|
1
|
+
"""Onde o histórico de uma tag persiste entre execuções.
|
|
2
|
+
|
|
3
|
+
A metade pura está em `domain/value_objects/tag_history.py`. Aqui mora só o
|
|
4
|
+
que ela não pode fazer: guardar o que foi visto para que a próxima execução
|
|
5
|
+
saiba comparar.
|
|
6
|
+
|
|
7
|
+
O cache já existente serve, com uma diferença que importa: entradas de cache
|
|
8
|
+
normais expiram porque uma resposta velha é pior que nenhuma. Um histórico é o
|
|
9
|
+
oposto -- ele *é* o passado, e vale mais quanto mais antigo. Por isso o TTL
|
|
10
|
+
aqui é de um ano e é renovado a cada gravação: um histórico que expira em 24h
|
|
11
|
+
nunca chega a registrar a segunda observação, e a pergunta que este módulo
|
|
12
|
+
existe para responder ("com que frequência esta tag muda?") ficaria sem
|
|
13
|
+
resposta para sempre.
|
|
14
|
+
|
|
15
|
+
Nada aqui levanta exceção para fora. O histórico enriquece o diagnóstico do
|
|
16
|
+
`base`; se o cache estiver indisponível ou corrompido, o diagnóstico continua,
|
|
17
|
+
apenas sem a frase extra. Um extra que derruba o principal não é um extra.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from loguru import logger
|
|
26
|
+
|
|
27
|
+
from dockerls.domain.value_objects.tag_history import TagHistory, record
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from dockerls.domain.interfaces.cache_store import CacheStoreInterface
|
|
31
|
+
|
|
32
|
+
#: Um ano. O histórico é o passado; ele não fica obsoleto, fica valioso.
|
|
33
|
+
HISTORY_TTL_SECONDS = 365 * 24 * 60 * 60
|
|
34
|
+
|
|
35
|
+
_KEY_PREFIX = "tag-history"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TagHistoryStore:
|
|
39
|
+
"""Lê e grava o histórico de digests por referência de tag."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, cache: CacheStoreInterface | None = None):
|
|
42
|
+
self._cache = cache
|
|
43
|
+
|
|
44
|
+
async def get(self, reference: str) -> TagHistory:
|
|
45
|
+
"""O histórico guardado, ou um vazio quando não há (ou não deu para ler)."""
|
|
46
|
+
if self._cache is None or not reference:
|
|
47
|
+
return TagHistory(reference=reference)
|
|
48
|
+
try:
|
|
49
|
+
raw = await self._cache.get(_key(reference))
|
|
50
|
+
except Exception as e: # pragma: no cover - o cache é o caminho instável
|
|
51
|
+
logger.debug(f"Could not read the history of {reference}: {e}")
|
|
52
|
+
return TagHistory(reference=reference)
|
|
53
|
+
return TagHistory.from_dict(reference, raw)
|
|
54
|
+
|
|
55
|
+
async def observe(self, reference: str, digest: str) -> TagHistory:
|
|
56
|
+
"""Incorpora o digest observado agora e devolve o histórico resultante.
|
|
57
|
+
|
|
58
|
+
Grava apenas quando algo mudou: reescrever a mesma entrada a cada
|
|
59
|
+
consulta só serviria para renovar o TTL, e a renovação já acontece na
|
|
60
|
+
gravação que importa.
|
|
61
|
+
"""
|
|
62
|
+
current = await self.get(reference)
|
|
63
|
+
updated = record(current, digest, datetime.now(UTC))
|
|
64
|
+
if updated is current or self._cache is None:
|
|
65
|
+
return updated
|
|
66
|
+
try:
|
|
67
|
+
await self._cache.set(
|
|
68
|
+
_key(reference), updated.to_dict(), ttl_seconds=HISTORY_TTL_SECONDS
|
|
69
|
+
)
|
|
70
|
+
except Exception as e: # pragma: no cover - o cache é o caminho instável
|
|
71
|
+
logger.debug(f"Could not write the history of {reference}: {e}")
|
|
72
|
+
return updated
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _key(reference: str) -> str:
|
|
76
|
+
return f"{_KEY_PREFIX}:{reference}"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Release the resources a use case borrowed, without letting cleanup lose a result.
|
|
2
|
+
|
|
3
|
+
Scanners hold temporary Trivy cache directories; repositories hold an
|
|
4
|
+
`httpx.AsyncClient` whose connection pool is kept alive for the whole run so
|
|
5
|
+
requests can reuse it. Both therefore have to be handed back, and the only
|
|
6
|
+
place that knows when a run is over is the use case that started it.
|
|
7
|
+
|
|
8
|
+
Everything here is duck-typed on `close()` for the same reason the rest of
|
|
9
|
+
the pipeline is: a test double, a repository with no pool, and a scanner
|
|
10
|
+
that owns nothing all need to work without implementing anything.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from loguru import logger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def close_quietly(*resources: Any) -> None:
|
|
21
|
+
"""Await `close()` on each resource that has one.
|
|
22
|
+
|
|
23
|
+
A failure to clean up is logged and swallowed. It must never replace the
|
|
24
|
+
result the caller is about to return: an unclosed socket is a smaller
|
|
25
|
+
problem than a completed scan being reported as an error, which is what
|
|
26
|
+
letting the exception escape a `finally` would do.
|
|
27
|
+
"""
|
|
28
|
+
for resource in resources:
|
|
29
|
+
if resource is None:
|
|
30
|
+
continue
|
|
31
|
+
close = getattr(resource, "close", None)
|
|
32
|
+
if not callable(close):
|
|
33
|
+
continue
|
|
34
|
+
try:
|
|
35
|
+
await close()
|
|
36
|
+
except Exception as e: # pragma: no cover - cleanup must not mask results
|
|
37
|
+
logger.warning(f"Cleanup failed for {type(resource).__name__}: {e}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sources_of(repository: Any) -> list[Any]:
|
|
41
|
+
"""The individual image sources behind a repository.
|
|
42
|
+
|
|
43
|
+
`CompositeImageRepository` exposes a real list of them; anything else --
|
|
44
|
+
a single client, or a test double whose attributes are auto-created --
|
|
45
|
+
is the one source it is.
|
|
46
|
+
"""
|
|
47
|
+
sources = getattr(repository, "sources", None)
|
|
48
|
+
if isinstance(sources, list | tuple):
|
|
49
|
+
return list(sources)
|
|
50
|
+
return [repository]
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Turn measurements into a verdict, and make the verdict explain itself.
|
|
2
|
+
|
|
3
|
+
The scoring pieces each answer one question -- how vulnerable, how hardened,
|
|
4
|
+
how much surface, how trustworthy is the evidence. This is where they are
|
|
5
|
+
put together into what the user actually asked for: *should I run this*, and
|
|
6
|
+
*why did it beat the alternative*.
|
|
7
|
+
|
|
8
|
+
Two rules govern the composition, and both exist because the obvious
|
|
9
|
+
alternative is wrong:
|
|
10
|
+
|
|
11
|
+
**Hardening never offsets vulnerabilities.** The dimensions are reported
|
|
12
|
+
side by side and are never summed. A perfectly configured image carrying an
|
|
13
|
+
unfixable CRITICAL is a perfectly configured vulnerable image, and the
|
|
14
|
+
production-ready verdict it gets is the one its CVEs earn. `SecurityTier`
|
|
15
|
+
already enforces the ceiling; nothing here may route around it.
|
|
16
|
+
|
|
17
|
+
**Confidence is a gate, not a decoration.** An UNVERIFIED candidate is not a
|
|
18
|
+
low-scoring candidate: it is one about which nothing is known, and the
|
|
19
|
+
ranking refuses to place it above anything that was actually measured.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from dockerls.application.services.cross_validation import CrossValidationOutcome
|
|
27
|
+
from dockerls.domain.value_objects.attack_surface import AttackSurfaceScore
|
|
28
|
+
from dockerls.domain.value_objects.confidence import (
|
|
29
|
+
Confidence,
|
|
30
|
+
ConfidenceAssessment,
|
|
31
|
+
ConfidenceInputs,
|
|
32
|
+
confidence_rank,
|
|
33
|
+
)
|
|
34
|
+
from dockerls.domain.value_objects.hardening import HardeningScore
|
|
35
|
+
from dockerls.domain.value_objects.production_readiness import (
|
|
36
|
+
ProductionReadiness,
|
|
37
|
+
ReadinessInputs,
|
|
38
|
+
)
|
|
39
|
+
from dockerls.domain.value_objects.security_tier import Tier
|
|
40
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from dockerls.application.dto.analysis import ImageAnalysis
|
|
44
|
+
from dockerls.domain.entities.image_facts import HardeningFacts
|
|
45
|
+
|
|
46
|
+
from dockerls.application.dto.analysis import DimensionReport
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def apply_facts(analysis: ImageAnalysis, facts: HardeningFacts) -> None:
|
|
50
|
+
"""Attach an evidence record and the two dimensions derived from it.
|
|
51
|
+
|
|
52
|
+
In-place, matching how `CrossValidator` annotates an analysis: the
|
|
53
|
+
pipeline enriches one object as evidence arrives rather than rebuilding
|
|
54
|
+
it at each stage.
|
|
55
|
+
"""
|
|
56
|
+
hardening = HardeningScore(facts)
|
|
57
|
+
surface = AttackSurfaceScore(facts)
|
|
58
|
+
|
|
59
|
+
analysis.facts = facts
|
|
60
|
+
analysis.hardening = DimensionReport(
|
|
61
|
+
score=hardening.value,
|
|
62
|
+
coverage=hardening.coverage,
|
|
63
|
+
reportable=hardening.is_reportable,
|
|
64
|
+
positives=hardening.strengths,
|
|
65
|
+
negatives=hardening.weaknesses,
|
|
66
|
+
undetermined=hardening.undetermined,
|
|
67
|
+
)
|
|
68
|
+
analysis.attack_surface = DimensionReport(
|
|
69
|
+
score=surface.value,
|
|
70
|
+
coverage=surface.coverage,
|
|
71
|
+
reportable=surface.is_reportable,
|
|
72
|
+
# Inverted relative to hardening on purpose: for surface, the
|
|
73
|
+
# elements *present* are what count against the image.
|
|
74
|
+
positives=surface.absent,
|
|
75
|
+
negatives=surface.present,
|
|
76
|
+
undetermined=[item.name for item in surface.items if not item.determined],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def finalize_verdict(analysis: ImageAnalysis, *, cross_validated: bool) -> None:
|
|
81
|
+
"""Set confidence and the explanation, once all evidence is in.
|
|
82
|
+
|
|
83
|
+
Called after tag verification and cross-validation, because both feed
|
|
84
|
+
the confidence assessment: computing it earlier would report a
|
|
85
|
+
confidence that ignores the checks still to come.
|
|
86
|
+
"""
|
|
87
|
+
assessment = ConfidenceAssessment(
|
|
88
|
+
ConfidenceInputs(
|
|
89
|
+
scan_verified=analysis.scan.is_verified,
|
|
90
|
+
cross_validated=cross_validated,
|
|
91
|
+
scanners_disagree=bool(analysis.scan_divergence),
|
|
92
|
+
scanners_differ_slightly=(
|
|
93
|
+
analysis.cross_validation == CrossValidationOutcome.MINOR_DIVERGENCE.value
|
|
94
|
+
),
|
|
95
|
+
digest_resolved=analysis.image.digest_known,
|
|
96
|
+
registry_verified=analysis.hub_tag_verified,
|
|
97
|
+
hardening_coverage=analysis.hardening.coverage,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
analysis.confidence = assessment.level
|
|
101
|
+
analysis.confidence_reasons = assessment.reasons
|
|
102
|
+
|
|
103
|
+
# Production readiness is decided here, not by the tier, and not before
|
|
104
|
+
# the evidence is in. `SecurityTier.production_ready` can only see the
|
|
105
|
+
# score, so a PARTIAL scan with no findings in the targets it managed to
|
|
106
|
+
# read produced tier A and "production ready" on the same analysis that
|
|
107
|
+
# reported UNVERIFIED. The policy below is the only thing that writes
|
|
108
|
+
# this field.
|
|
109
|
+
readiness = ProductionReadiness.evaluate(
|
|
110
|
+
ReadinessInputs(
|
|
111
|
+
tier=Tier(analysis.tier),
|
|
112
|
+
confidence=analysis.confidence,
|
|
113
|
+
scan_verified=analysis.scan.is_verified,
|
|
114
|
+
eol=analysis.eol_status,
|
|
115
|
+
critical_count=analysis.scan.critical_count,
|
|
116
|
+
high_count=analysis.scan.high_count,
|
|
117
|
+
unfixable_critical_count=(
|
|
118
|
+
analysis.scan.critical_count - analysis.scan.fixable_critical_count
|
|
119
|
+
),
|
|
120
|
+
has_material_divergence=bool(analysis.scan_divergence),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
analysis.production_ready = readiness.is_ready
|
|
124
|
+
analysis.readiness_blockers = readiness.codes
|
|
125
|
+
analysis.readiness_reasons = readiness.reasons
|
|
126
|
+
|
|
127
|
+
analysis.why = _why(analysis)
|
|
128
|
+
analysis.trade_offs = _trade_offs(analysis)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def ranking_key(analysis: ImageAnalysis) -> tuple[float, ...]:
|
|
132
|
+
"""Total ordering across sources, best first under `reverse=True`.
|
|
133
|
+
|
|
134
|
+
The order of the terms *is* the policy, so it is stated here once and
|
|
135
|
+
read the same way by `recommend`, `alternatives` and the advisor:
|
|
136
|
+
|
|
137
|
+
1. **confidence** -- an unverified candidate never outranks a measured
|
|
138
|
+
one, whatever its numbers say;
|
|
139
|
+
2. **security score** -- the measured vulnerability position, which is
|
|
140
|
+
what the whole exercise is about;
|
|
141
|
+
3. **hardening**, but only when enough of it was determined to mean
|
|
142
|
+
something; a thin-coverage 100 must not beat a fully-inspected 85;
|
|
143
|
+
4. **attack surface**, negated so less surface ranks higher;
|
|
144
|
+
5. **remediation score** -- between two equivalent images, prefer the
|
|
145
|
+
one whose findings can actually be fixed.
|
|
146
|
+
|
|
147
|
+
Hardening enters only after the vulnerability position, which is the
|
|
148
|
+
structural reason it can never mask a CRITICAL: no value of terms 3-5
|
|
149
|
+
can move a candidate past a difference in term 2.
|
|
150
|
+
"""
|
|
151
|
+
return (
|
|
152
|
+
float(confidence_rank(analysis.confidence)),
|
|
153
|
+
analysis.security_score,
|
|
154
|
+
analysis.hardening.score if analysis.hardening.reportable else 0.0,
|
|
155
|
+
-(analysis.attack_surface.score if analysis.attack_surface.reportable else 0.0),
|
|
156
|
+
float(analysis.remediation_score),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def rank(analyses: list[ImageAnalysis]) -> list[ImageAnalysis]:
|
|
161
|
+
"""Order candidates from every source under one comparable policy."""
|
|
162
|
+
return sorted(analyses, key=lambda analysis: ranking_key(analysis), reverse=True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _why(analysis: ImageAnalysis) -> list[str]:
|
|
166
|
+
"""The case for this image, in facts a reader can check.
|
|
167
|
+
|
|
168
|
+
Only statements backed by something that was actually determined get in.
|
|
169
|
+
"No critical vulnerabilities" is earned by a completed scan; "runs as
|
|
170
|
+
non-root" is earned by a config that was read or a declaration that was
|
|
171
|
+
made, and the phrasing says which.
|
|
172
|
+
"""
|
|
173
|
+
scan = analysis.scan
|
|
174
|
+
reasons: list[str] = []
|
|
175
|
+
|
|
176
|
+
if scan.critical_count == 0:
|
|
177
|
+
reasons.append("no CRITICAL vulnerabilities")
|
|
178
|
+
if scan.high_count == 0:
|
|
179
|
+
reasons.append("no HIGH vulnerabilities")
|
|
180
|
+
# "No known-exploited vulnerabilities" is the strongest claim this tool
|
|
181
|
+
# makes about real-world exploitation, and it may only be made when the
|
|
182
|
+
# KEV catalogue actually answered. With the feed unreachable every CVE
|
|
183
|
+
# comes back `exploit_known=False`, and stating the claim on that basis
|
|
184
|
+
# would be reporting a failed lookup as a security property.
|
|
185
|
+
#
|
|
186
|
+
# A CVE the catalogue *does* list is the mirror case, and it belongs in
|
|
187
|
+
# `_trade_offs`, not here: it used to land in this list, so a log4j RCE
|
|
188
|
+
# under active exploitation (CVE-2021-44228, CISA KEV) printed as a `+`
|
|
189
|
+
# reason to pick the image. Exploitation observed in the wild is never a
|
|
190
|
+
# point in an image's favour.
|
|
191
|
+
checked = [v for v in scan.vulnerabilities if v.kev_status.is_known]
|
|
192
|
+
if checked and not any(v.exploit_known for v in checked):
|
|
193
|
+
reasons.append(
|
|
194
|
+
f"no known-exploited (CISA KEV) vulnerabilities among the "
|
|
195
|
+
f"{len(checked)} finding(s) checked"
|
|
196
|
+
)
|
|
197
|
+
if analysis.eol_status.is_false:
|
|
198
|
+
reasons.append("not end-of-life")
|
|
199
|
+
if analysis.is_lts:
|
|
200
|
+
reasons.append("long-term-support release")
|
|
201
|
+
|
|
202
|
+
facts = analysis.facts
|
|
203
|
+
if facts.runs_as_non_root.is_true:
|
|
204
|
+
reasons.append(f"runs as a non-root account ({facts.source_of('runs_as_non_root').value})")
|
|
205
|
+
if analysis.hardening.reportable:
|
|
206
|
+
reasons.extend(analysis.hardening.positives)
|
|
207
|
+
if analysis.attack_surface.reportable and analysis.attack_surface.positives:
|
|
208
|
+
reasons.extend(analysis.attack_surface.positives)
|
|
209
|
+
|
|
210
|
+
if analysis.image.digest_known:
|
|
211
|
+
reasons.append("pinned to a resolved manifest digest")
|
|
212
|
+
if analysis.hub_tag_verified is True:
|
|
213
|
+
reasons.append("tag confirmed in its source registry")
|
|
214
|
+
if cross_validation_agreed(analysis):
|
|
215
|
+
reasons.append("two scanners agreed on the vulnerability counts")
|
|
216
|
+
|
|
217
|
+
return _deduplicate(reasons)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def cross_validation_agreed(analysis: ImageAnalysis) -> bool:
|
|
221
|
+
"""A second scanner ran and did not disagree materially.
|
|
222
|
+
|
|
223
|
+
Distinguished from "no second scanner ran": the evidence path for the
|
|
224
|
+
secondary scanner is what proves one did.
|
|
225
|
+
"""
|
|
226
|
+
if analysis.cross_validation == CrossValidationOutcome.AGREEMENT.value:
|
|
227
|
+
return True
|
|
228
|
+
# Fall back to the older signal for analyses produced before the outcome
|
|
229
|
+
# was recorded (a cache entry, a hand-built DTO): two evidence files and
|
|
230
|
+
# no material dispute is what "agreed" used to mean.
|
|
231
|
+
scanners = set(analysis.evidence_paths)
|
|
232
|
+
return len(scanners) > 1 and not analysis.scan_divergence
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _trade_offs(analysis: ImageAnalysis) -> list[str]:
|
|
236
|
+
"""What this image costs, stated next to what it offers."""
|
|
237
|
+
costs: list[str] = []
|
|
238
|
+
scan = analysis.scan
|
|
239
|
+
|
|
240
|
+
if scan.critical_count:
|
|
241
|
+
costs.append(f"{scan.critical_count} CRITICAL vulnerability(ies) present")
|
|
242
|
+
if scan.high_count:
|
|
243
|
+
costs.append(f"{scan.high_count} HIGH vulnerability(ies) present")
|
|
244
|
+
if analysis.eol_status.is_true:
|
|
245
|
+
costs.append("this release is end-of-life and will not receive security fixes")
|
|
246
|
+
elif analysis.eol_status is Tristate.UNKNOWN:
|
|
247
|
+
costs.append(
|
|
248
|
+
"end-of-life status could not be determined: this is not a statement that "
|
|
249
|
+
"the release is supported"
|
|
250
|
+
)
|
|
251
|
+
if analysis.scan.total_count and not any(
|
|
252
|
+
v.kev_status.is_known for v in analysis.scan.vulnerabilities
|
|
253
|
+
):
|
|
254
|
+
costs.append("exploitation status (CISA KEV) could not be determined for any finding")
|
|
255
|
+
exploited = sum(1 for v in scan.vulnerabilities if v.exploit_known)
|
|
256
|
+
if exploited:
|
|
257
|
+
costs.append(f"{exploited} known-exploited (CISA KEV) vulnerability(ies) present")
|
|
258
|
+
if analysis.scan_divergence:
|
|
259
|
+
costs.append(f"scanners disagree: {analysis.scan_divergence}")
|
|
260
|
+
if analysis.confidence is not Confidence.HIGH:
|
|
261
|
+
costs.extend(analysis.confidence_reasons)
|
|
262
|
+
|
|
263
|
+
facts = analysis.facts
|
|
264
|
+
if facts.runs_as_non_root.is_false:
|
|
265
|
+
costs.append("runs as root by default")
|
|
266
|
+
if analysis.hardening.reportable:
|
|
267
|
+
costs.extend(analysis.hardening.negatives)
|
|
268
|
+
if analysis.attack_surface.reportable:
|
|
269
|
+
costs.extend(analysis.attack_surface.negatives)
|
|
270
|
+
costs.extend(facts.conflicts)
|
|
271
|
+
|
|
272
|
+
if not analysis.image.digest_known:
|
|
273
|
+
costs.append("no manifest digest resolved: the tag may move under you")
|
|
274
|
+
declared = analysis.image.declared
|
|
275
|
+
if declared is not None and declared.is_dev_variant:
|
|
276
|
+
costs.append(
|
|
277
|
+
f"this is the '{declared.variant}' variant, which ships build tooling by design"
|
|
278
|
+
)
|
|
279
|
+
if facts.has_shell is Tristate.UNKNOWN and facts.has_package_manager is Tristate.UNKNOWN:
|
|
280
|
+
costs.append("image contents could not be inspected: shell and package manager unknown")
|
|
281
|
+
|
|
282
|
+
return _deduplicate(costs)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _deduplicate(items: list[str]) -> list[str]:
|
|
286
|
+
"""Preserve order, drop repeats.
|
|
287
|
+
|
|
288
|
+
Reasons are gathered from several models that legitimately observe the
|
|
289
|
+
same property, and a list that says "no shell present" twice reads as
|
|
290
|
+
sloppy rather than thorough.
|
|
291
|
+
"""
|
|
292
|
+
seen: set[str] = set()
|
|
293
|
+
unique: list[str] = []
|
|
294
|
+
for item in items:
|
|
295
|
+
if item and item not in seen:
|
|
296
|
+
seen.add(item)
|
|
297
|
+
unique.append(item)
|
|
298
|
+
return unique
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The current stable versions of a runtime or OS, read from the registry.
|
|
2
|
+
|
|
3
|
+
`RUNTIME_BASES` in `base_recipe.py` hardcodes one version per (runtime,
|
|
4
|
+
family) -- Alpine 3.21, Node 22, Python 3.12 -- and that stays the default
|
|
5
|
+
when nobody asks for anything else. This module answers a narrower
|
|
6
|
+
question for `--os-version`/`--runtime-version`: what versions actually
|
|
7
|
+
exist right now, so a menu can offer "the two latest" without a number
|
|
8
|
+
baked into this file going stale the day a project ships its next release.
|
|
9
|
+
|
|
10
|
+
Nothing here decides which version is *safer* -- that is still `recommend`
|
|
11
|
+
and a real scan's job. This only says what is published.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from dockerls.domain.value_objects.base_recipe import OsFamily, Runtime
|
|
21
|
+
from dockerls.integrations.dockerhub.client import DockerHubClient
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
|
|
26
|
+
#: Which Docker Hub repository names this (runtime, family), and the tag
|
|
27
|
+
#: shape that names a *stable* release in that family -- one capture group,
|
|
28
|
+
#: the version. `python:3.13-rc-alpine` and `node:22-alpine3.20-slim` do not
|
|
29
|
+
#: match: a release candidate or a variant tag is not what "the latest
|
|
30
|
+
#: stable version" means here.
|
|
31
|
+
_SOURCES: dict[tuple[Runtime, OsFamily], tuple[str, re.Pattern[str]]] = {
|
|
32
|
+
(Runtime.NONE, OsFamily.ALPINE): ("alpine", re.compile(r"^(\d+\.\d+)$")),
|
|
33
|
+
(Runtime.NONE, OsFamily.DEBIAN): ("debian", re.compile(r"^(\d+)-slim$")),
|
|
34
|
+
(Runtime.NONE, OsFamily.UBUNTU): ("ubuntu", re.compile(r"^(\d+\.\d+)$")),
|
|
35
|
+
(Runtime.JAVA, OsFamily.ALPINE): ("eclipse-temurin", re.compile(r"^(\d+)-jre-alpine$")),
|
|
36
|
+
(Runtime.JAVA, OsFamily.DEBIAN): ("eclipse-temurin", re.compile(r"^(\d+)-jre$")),
|
|
37
|
+
(Runtime.JAVA, OsFamily.UBUNTU): ("eclipse-temurin", re.compile(r"^(\d+)-jre-noble$")),
|
|
38
|
+
(Runtime.NODE, OsFamily.ALPINE): ("node", re.compile(r"^(\d+)-alpine$")),
|
|
39
|
+
(Runtime.NODE, OsFamily.DEBIAN): ("node", re.compile(r"^(\d+)-slim$")),
|
|
40
|
+
(Runtime.PYTHON, OsFamily.ALPINE): ("python", re.compile(r"^(\d+\.\d+)-alpine$")),
|
|
41
|
+
(Runtime.PYTHON, OsFamily.DEBIAN): ("python", re.compile(r"^(\d+\.\d+)-slim-bookworm$")),
|
|
42
|
+
(Runtime.GO, OsFamily.ALPINE): ("golang", re.compile(r"^(\d+\.\d+)-alpine$")),
|
|
43
|
+
(Runtime.GO, OsFamily.DEBIAN): ("golang", re.compile(r"^(\d+\.\d+)-bookworm$")),
|
|
44
|
+
(Runtime.RUBY, OsFamily.ALPINE): ("ruby", re.compile(r"^(\d+\.\d+)-alpine$")),
|
|
45
|
+
(Runtime.RUBY, OsFamily.DEBIAN): ("ruby", re.compile(r"^(\d+\.\d+)-slim-bookworm$")),
|
|
46
|
+
(Runtime.PHP, OsFamily.ALPINE): ("php", re.compile(r"^(\d+\.\d+)-cli-alpine$")),
|
|
47
|
+
(Runtime.PHP, OsFamily.DEBIAN): ("php", re.compile(r"^(\d+\.\d+)-cli-bookworm$")),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class VersionChoice:
|
|
53
|
+
"""One version this (runtime, family) can be pinned to, and the tag it
|
|
54
|
+
resolves to today."""
|
|
55
|
+
|
|
56
|
+
version: str
|
|
57
|
+
tag: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def supports_version_discovery(runtime: Runtime, family: OsFamily) -> bool:
|
|
61
|
+
return (runtime, family) in _SOURCES
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _sort_key(version: str) -> tuple[int, ...]:
|
|
65
|
+
return tuple(int(part) for part in version.split("."))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def discover_versions(
|
|
69
|
+
runtime: Runtime,
|
|
70
|
+
family: OsFamily,
|
|
71
|
+
*,
|
|
72
|
+
count: int = 2,
|
|
73
|
+
client: DockerHubClient | None = None,
|
|
74
|
+
) -> Sequence[VersionChoice]:
|
|
75
|
+
"""The `count` newest stable versions published for this combination,
|
|
76
|
+
newest first.
|
|
77
|
+
|
|
78
|
+
Returns an empty sequence -- never raises -- when the registry cannot
|
|
79
|
+
be reached or this combination has no known tag shape: an empty result
|
|
80
|
+
is "could not measure this", and the caller falls back to the catalog
|
|
81
|
+
default rather than being handed a guess dressed up as one.
|
|
82
|
+
"""
|
|
83
|
+
source = _SOURCES.get((runtime, family))
|
|
84
|
+
if source is None:
|
|
85
|
+
return ()
|
|
86
|
+
repository, pattern = source
|
|
87
|
+
|
|
88
|
+
owns_client = client is None
|
|
89
|
+
client = client or DockerHubClient()
|
|
90
|
+
try:
|
|
91
|
+
# `search_tags` already degrades to a partial (or empty) result on
|
|
92
|
+
# any HTTP error instead of raising -- there is nothing further to
|
|
93
|
+
# catch here.
|
|
94
|
+
tags = await client.search_tags(repository, limit=100)
|
|
95
|
+
finally:
|
|
96
|
+
if owns_client:
|
|
97
|
+
await client.close()
|
|
98
|
+
|
|
99
|
+
by_version: dict[str, str] = {}
|
|
100
|
+
for image in tags:
|
|
101
|
+
match = pattern.match(image.tag)
|
|
102
|
+
if not match:
|
|
103
|
+
continue
|
|
104
|
+
version = match.group(1)
|
|
105
|
+
by_version.setdefault(version, image.tag)
|
|
106
|
+
|
|
107
|
+
ordered = sorted(by_version, key=_sort_key, reverse=True)
|
|
108
|
+
return [VersionChoice(version=v, tag=by_version[v]) for v in ordered[:count]]
|
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Use case para análise de Dockerfiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from dockerls.domain.entities.dockerfile_analysis import (
|
|
11
|
+
DockerfileAnalysis,
|
|
12
|
+
DockerfileValidationResult,
|
|
13
|
+
HardeningRule,
|
|
14
|
+
)
|
|
15
|
+
from dockerls.domain.interfaces.dockerfile_validator import (
|
|
16
|
+
DockerfileValidatorInterface,
|
|
17
|
+
HardeningTemplateProvider,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AnalyzeDockerfileRequest:
|
|
23
|
+
"""Request para análise de Dockerfile."""
|
|
24
|
+
|
|
25
|
+
dockerfile_path: str | Path
|
|
26
|
+
include_suggestions: bool = True
|
|
27
|
+
validate_only: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class AnalyzeDockerfileResponse:
|
|
32
|
+
"""Resposta da análise de Dockerfile."""
|
|
33
|
+
|
|
34
|
+
success: bool
|
|
35
|
+
analysis: DockerfileAnalysis | None = None
|
|
36
|
+
validation: DockerfileValidationResult | None = None
|
|
37
|
+
suggestions: list[HardeningRule] | None = None
|
|
38
|
+
error: str | None = None
|
|
39
|
+
|
|
40
|
+
def model_dump(self) -> dict[str, Any]:
|
|
41
|
+
"""Retorna dicionário serializável."""
|
|
42
|
+
return {
|
|
43
|
+
"success": self.success,
|
|
44
|
+
"analysis": self.analysis.model_dump() if self.analysis else None,
|
|
45
|
+
"validation": self.validation.model_dump() if self.validation else None,
|
|
46
|
+
"suggestions": [s.model_dump() for s in self.suggestions] if self.suggestions else [],
|
|
47
|
+
"error": self.error,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AnalyzeDockerfileUseCase:
|
|
52
|
+
"""Caso de uso para análise de Dockerfiles."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
validator: DockerfileValidatorInterface,
|
|
57
|
+
template_provider: HardeningTemplateProvider | None = None,
|
|
58
|
+
):
|
|
59
|
+
self._validator = validator
|
|
60
|
+
self._template_provider = template_provider
|
|
61
|
+
|
|
62
|
+
def execute(self, request: AnalyzeDockerfileRequest) -> AnalyzeDockerfileResponse:
|
|
63
|
+
"""Executa a análise do Dockerfile."""
|
|
64
|
+
try:
|
|
65
|
+
path = Path(request.dockerfile_path)
|
|
66
|
+
if path.is_dir():
|
|
67
|
+
path = path / "Dockerfile"
|
|
68
|
+
|
|
69
|
+
if not path.exists():
|
|
70
|
+
return AnalyzeDockerfileResponse(
|
|
71
|
+
success=False,
|
|
72
|
+
error=f"Dockerfile not found at {path}",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Validação sempre é executada
|
|
76
|
+
validation = self._validator.validate(path)
|
|
77
|
+
|
|
78
|
+
if request.validate_only:
|
|
79
|
+
return AnalyzeDockerfileResponse(
|
|
80
|
+
success=True,
|
|
81
|
+
validation=validation,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Análise completa
|
|
85
|
+
analysis = self._validator.analyze(path)
|
|
86
|
+
|
|
87
|
+
# Sugestões se solicitado
|
|
88
|
+
suggestions = []
|
|
89
|
+
if request.include_suggestions:
|
|
90
|
+
suggestions = self._validator.suggest_hardening(path)
|
|
91
|
+
|
|
92
|
+
return AnalyzeDockerfileResponse(
|
|
93
|
+
success=True,
|
|
94
|
+
analysis=analysis,
|
|
95
|
+
validation=validation,
|
|
96
|
+
suggestions=suggestions,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
return AnalyzeDockerfileResponse(
|
|
101
|
+
success=False,
|
|
102
|
+
error=str(e),
|
|
103
|
+
)
|