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,1795 @@
|
|
|
1
|
+
"""Use case para construir imagens Docker com segurança."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
# subprocess é necessário para invocar docker/trivy/grype; todas as chamadas
|
|
11
|
+
# usam listas de argumentos, sem shell, com argv[0] resolvido por caminho.
|
|
12
|
+
import subprocess # nosec B404
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from loguru import logger
|
|
19
|
+
|
|
20
|
+
from dockerls.domain.entities.dockerfile_analysis import (
|
|
21
|
+
DockerfileAnalysis,
|
|
22
|
+
DockerfileValidationResult,
|
|
23
|
+
HardeningRule,
|
|
24
|
+
ValidationStatus,
|
|
25
|
+
)
|
|
26
|
+
from dockerls.domain.entities.vulnerability import Severity, Vulnerability
|
|
27
|
+
from dockerls.domain.value_objects.base_upgrade import parse_bases
|
|
28
|
+
from dockerls.domain.value_objects.build_policy import (
|
|
29
|
+
BaseFact,
|
|
30
|
+
BuildPolicy,
|
|
31
|
+
PolicyFacts,
|
|
32
|
+
PolicyViolation,
|
|
33
|
+
evaluate,
|
|
34
|
+
)
|
|
35
|
+
from dockerls.domain.value_objects.gate import (
|
|
36
|
+
Finding,
|
|
37
|
+
GateOutcome,
|
|
38
|
+
GateSet,
|
|
39
|
+
GateVerdict,
|
|
40
|
+
)
|
|
41
|
+
from dockerls.domain.value_objects.image_reference import registry_host_of
|
|
42
|
+
from dockerls.domain.value_objects.inheritance import (
|
|
43
|
+
InheritanceReport,
|
|
44
|
+
attribute,
|
|
45
|
+
unavailable,
|
|
46
|
+
)
|
|
47
|
+
from dockerls.domain.value_objects.provenance import (
|
|
48
|
+
ArtifactDigests,
|
|
49
|
+
BuildProvenance,
|
|
50
|
+
SourceDigests,
|
|
51
|
+
)
|
|
52
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
53
|
+
from dockerls.exit_codes import EXIT_ERROR, EXIT_OK, EXIT_POLICY
|
|
54
|
+
from dockerls.infrastructure.hashing import ContextTooLargeError, hash_context, hash_file
|
|
55
|
+
from dockerls.utils.executables import ExecutableNotFoundError, resolve_executable
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from dockerls.application.use_cases.analyze_dockerfile import AnalyzeDockerfileResponse
|
|
59
|
+
from dockerls.domain.interfaces.dockerfile_validator import (
|
|
60
|
+
DockerfileValidatorInterface,
|
|
61
|
+
HardeningTemplateProvider,
|
|
62
|
+
)
|
|
63
|
+
from dockerls.integrations.threat_intel.client import ThreatIntelClient
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class BuildOptions:
|
|
68
|
+
"""Opções de build."""
|
|
69
|
+
|
|
70
|
+
tag: str
|
|
71
|
+
dockerfile_path: str = "Dockerfile"
|
|
72
|
+
context_path: str = "."
|
|
73
|
+
no_cache: bool = False
|
|
74
|
+
build_args: dict[str, str] | None = None
|
|
75
|
+
labels: dict[str, str] | None = None
|
|
76
|
+
platform: str | None = None
|
|
77
|
+
target: str | None = None
|
|
78
|
+
pull: bool = True
|
|
79
|
+
buildkit: bool = True
|
|
80
|
+
secrets: dict[str, str] | None = None # id -> file_path
|
|
81
|
+
ssh: list[str] | None = None # SSH agents
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class BuildResult:
|
|
86
|
+
"""Resultado do build."""
|
|
87
|
+
|
|
88
|
+
success: bool
|
|
89
|
+
image_tag: str | None = None
|
|
90
|
+
image_id: str | None = None
|
|
91
|
+
image_sha256: str | None = None
|
|
92
|
+
build_time_seconds: float = 0.0
|
|
93
|
+
layers_count: int = 0
|
|
94
|
+
image_size_bytes: int = 0
|
|
95
|
+
error_message: str | None = None
|
|
96
|
+
logs: list[str] = field(default_factory=list)
|
|
97
|
+
warnings: list[str] = field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class ScanResult:
|
|
102
|
+
"""Resultado do scan de segurança."""
|
|
103
|
+
|
|
104
|
+
critical: int = 0
|
|
105
|
+
high: int = 0
|
|
106
|
+
medium: int = 0
|
|
107
|
+
low: int = 0
|
|
108
|
+
unknown: int = 0
|
|
109
|
+
total_vulnerabilities: int = 0
|
|
110
|
+
fixable: int = 0
|
|
111
|
+
scan_tool: str = "trivy"
|
|
112
|
+
scan_time_seconds: float = 0.0
|
|
113
|
+
vulnerabilities: list[dict[str, Any]] = field(default_factory=list)
|
|
114
|
+
sbom_components_count: int = 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class BuildReport:
|
|
119
|
+
"""Relatório completo de build."""
|
|
120
|
+
|
|
121
|
+
build_id: str
|
|
122
|
+
timestamp: str
|
|
123
|
+
image: str
|
|
124
|
+
dockerfile_path: str
|
|
125
|
+
validation: dict[str, Any]
|
|
126
|
+
scan_results: dict[str, Any] | None = None
|
|
127
|
+
security_score: int = 0
|
|
128
|
+
security_tier: str = "F"
|
|
129
|
+
layers: list[dict[str, Any]] = field(default_factory=list)
|
|
130
|
+
recommendations: list[dict[str, Any]] = field(default_factory=list)
|
|
131
|
+
sbom: dict[str, Any] | None = None
|
|
132
|
+
build_metadata: dict[str, Any] | None = None
|
|
133
|
+
remediation_history: list[dict[str, Any]] = field(default_factory=list)
|
|
134
|
+
auto_remediated: bool = False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class BuildImageRequest:
|
|
139
|
+
"""Request para build de imagem."""
|
|
140
|
+
|
|
141
|
+
context_path: str
|
|
142
|
+
tag: str
|
|
143
|
+
dockerfile_path: str = "Dockerfile"
|
|
144
|
+
hardened: bool = False
|
|
145
|
+
base_template: str | None = None
|
|
146
|
+
scan: bool = True
|
|
147
|
+
validate_only: bool = False
|
|
148
|
+
suggest_only: bool = False
|
|
149
|
+
no_cache: bool = False
|
|
150
|
+
build_args: dict[str, str] | None = None
|
|
151
|
+
labels: dict[str, str] | None = None
|
|
152
|
+
fail_on: str | None = None # "critical", "high"
|
|
153
|
+
ci_mode: bool = False
|
|
154
|
+
verbose: bool = False
|
|
155
|
+
force: bool = False
|
|
156
|
+
push: bool = False
|
|
157
|
+
auto_remediate: bool = False
|
|
158
|
+
max_remediation_rounds: int = 3
|
|
159
|
+
target_zero_vulns: bool = False
|
|
160
|
+
#: Onde arquivar o documento de procedência. Vazio desliga o arquivamento
|
|
161
|
+
#: mas não a medição: o registro continua na resposta.
|
|
162
|
+
provenance_path: str = ""
|
|
163
|
+
#: Destino completo da publicação (`meuacr.azurecr.io/apps/dockerls:1.5.0`).
|
|
164
|
+
#: Vazio significa publicar a própria tag local, que só funciona quando ela
|
|
165
|
+
#: já nomeia um registry -- e era o comportamento anterior, que falhava com
|
|
166
|
+
#: "denied" para toda tag sem host.
|
|
167
|
+
push_reference: str = ""
|
|
168
|
+
#: A política declarada em `.dockerls-policy.yaml`, já carregada pela
|
|
169
|
+
#: camada CLI. O caso de uso confere; ler o arquivo é trabalho da borda.
|
|
170
|
+
policy: BuildPolicy | None = None
|
|
171
|
+
#: Escanear também a base declarada, para dizer de quem é cada CVE. Custa
|
|
172
|
+
#: um segundo scan, e por isso é escolha e não padrão.
|
|
173
|
+
attribute_findings: bool = False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class BuildImageResponse:
|
|
178
|
+
"""Resposta do build de imagem.
|
|
179
|
+
|
|
180
|
+
`validation` e `analysis` carregam o resultado bruto da validação para
|
|
181
|
+
que a camada CLI possa renderizar a tabela de checks. Sem eles o
|
|
182
|
+
comando só sabia dizer "falhou", sem qual regra falhou.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
success: bool
|
|
186
|
+
image_tag: str | None = None
|
|
187
|
+
image_sha256: str | None = None
|
|
188
|
+
report: BuildReport | None = None
|
|
189
|
+
validation: DockerfileValidationResult | None = None
|
|
190
|
+
analysis: DockerfileAnalysis | None = None
|
|
191
|
+
recommendations: list[HardeningRule] = field(default_factory=list)
|
|
192
|
+
#: A cadeia entre o que entrou no build e o que saiu dele. Presente em
|
|
193
|
+
#: todo build que chegou a produzir imagem.
|
|
194
|
+
provenance: BuildProvenance | None = None
|
|
195
|
+
#: Regras de `.dockerls-policy.yaml` que este build não cumpriu.
|
|
196
|
+
policy_violations: list[PolicyViolation] = field(default_factory=list)
|
|
197
|
+
#: De quem é cada vulnerabilidade: da base declarada ou das suas camadas.
|
|
198
|
+
inheritance: InheritanceReport | None = None
|
|
199
|
+
error: str | None = None
|
|
200
|
+
exit_code: int = EXIT_OK
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class BuildImageUseCase:
|
|
204
|
+
"""Caso de uso para construção segura de imagens Docker."""
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
validator: DockerfileValidatorInterface,
|
|
209
|
+
template_provider: HardeningTemplateProvider,
|
|
210
|
+
threat_intel: ThreatIntelClient | None = None,
|
|
211
|
+
):
|
|
212
|
+
self.validator = validator
|
|
213
|
+
self.template_provider = template_provider
|
|
214
|
+
# Opcional: sem ele os portões `kev` e `epss` não têm o que
|
|
215
|
+
# consultar, e dizem isso em vez de aprovar por omissão.
|
|
216
|
+
self.threat_intel = threat_intel
|
|
217
|
+
|
|
218
|
+
def execute(self, request: BuildImageRequest) -> BuildImageResponse:
|
|
219
|
+
"""Executa o build seguro da imagem."""
|
|
220
|
+
logger.debug(f"Iniciando build seguro: {request.context_path}")
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
# 1. Validar Dockerfile. Uma falha aqui (Dockerfile ausente,
|
|
224
|
+
# ilegível) é erro de execução, não violação de política, e
|
|
225
|
+
# --force não cria um Dockerfile que não existe.
|
|
226
|
+
validation_result = self._validate_dockerfile(request)
|
|
227
|
+
if not validation_result.success:
|
|
228
|
+
return BuildImageResponse(
|
|
229
|
+
success=False,
|
|
230
|
+
error=validation_result.error or "Dockerfile validation could not run",
|
|
231
|
+
exit_code=EXIT_ERROR,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# 2. Modo validate-only. A política entra aqui pelo subconjunto
|
|
235
|
+
# estático: sem build não há scan, procedência nem imagem, e
|
|
236
|
+
# aplicar as regras que dependem deles produziria uma violação
|
|
237
|
+
# por execução dizendo sempre a mesma coisa. O que dá para
|
|
238
|
+
# conferir só lendo o Dockerfile é conferido -- e é justamente
|
|
239
|
+
# o que evita descobrir um rótulo faltando depois de dez
|
|
240
|
+
# minutos de build.
|
|
241
|
+
if request.validate_only:
|
|
242
|
+
response = self._format_validation_response(validation_result)
|
|
243
|
+
response.policy_violations = self._preflight(request, validation_result)
|
|
244
|
+
if response.policy_violations and response.exit_code == EXIT_OK:
|
|
245
|
+
response.success = False
|
|
246
|
+
response.error = (
|
|
247
|
+
f"{len(response.policy_violations)} policy rule(s) not met among what can "
|
|
248
|
+
"be checked without building"
|
|
249
|
+
)
|
|
250
|
+
response.exit_code = EXIT_POLICY
|
|
251
|
+
return response
|
|
252
|
+
|
|
253
|
+
# 3. Modo suggest-only
|
|
254
|
+
if request.suggest_only:
|
|
255
|
+
return self._format_suggestions_response(validation_result)
|
|
256
|
+
|
|
257
|
+
# 3b. Validação com erros barra o build (a menos que --force).
|
|
258
|
+
# A resposta carrega os checks para o CLI dizer o que falhou.
|
|
259
|
+
validation = validation_result.validation
|
|
260
|
+
if validation is not None and validation.errors > 0 and not request.force:
|
|
261
|
+
return self._format_validation_response(validation_result)
|
|
262
|
+
|
|
263
|
+
# 4. Gerar Dockerfile hardened se solicitado
|
|
264
|
+
dockerfile_path = request.dockerfile_path
|
|
265
|
+
if request.hardened or request.base_template:
|
|
266
|
+
dockerfile_path = self._generate_hardened_dockerfile(
|
|
267
|
+
request.context_path,
|
|
268
|
+
request.base_template or "node",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# 4b. Digerir a entrada **antes** de construir. Sem isto, dois
|
|
272
|
+
# builds do mesmo --tag produzem relatórios indistinguíveis
|
|
273
|
+
# mesmo partindo de Dockerfiles diferentes, e o scan não fica
|
|
274
|
+
# ligado a artefato nenhum.
|
|
275
|
+
started_at = datetime.now(tz=UTC).isoformat()
|
|
276
|
+
source_before = self._digest_source(request.context_path, dockerfile_path)
|
|
277
|
+
|
|
278
|
+
# 5. Construir imagem
|
|
279
|
+
build_result = self._build_image(
|
|
280
|
+
context_path=request.context_path,
|
|
281
|
+
dockerfile_path=dockerfile_path,
|
|
282
|
+
tag=request.tag,
|
|
283
|
+
options=BuildOptions(
|
|
284
|
+
tag=request.tag,
|
|
285
|
+
dockerfile_path=dockerfile_path,
|
|
286
|
+
context_path=request.context_path,
|
|
287
|
+
no_cache=request.no_cache,
|
|
288
|
+
build_args=request.build_args,
|
|
289
|
+
labels=request.labels,
|
|
290
|
+
buildkit=True,
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if not build_result.success:
|
|
295
|
+
return BuildImageResponse(
|
|
296
|
+
success=False,
|
|
297
|
+
error=build_result.error_message,
|
|
298
|
+
exit_code=EXIT_ERROR,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# 5b. Digerir a entrada de novo. A comparação é o que transforma
|
|
302
|
+
# o registro em controle: uma entrada que mudou durante o
|
|
303
|
+
# build não é a entrada que a imagem representa.
|
|
304
|
+
source_after = self._digest_source(request.context_path, dockerfile_path)
|
|
305
|
+
|
|
306
|
+
# 6. Scan pós-build
|
|
307
|
+
scan_result = None
|
|
308
|
+
if request.scan:
|
|
309
|
+
scan_result = self._enrich(self._scan_image(request.tag))
|
|
310
|
+
|
|
311
|
+
# 6b. Ciclo de Auto-Remediação Iterativo (Zero Vulnerabilidades)
|
|
312
|
+
remediation_history: list[dict[str, Any]] = []
|
|
313
|
+
if (
|
|
314
|
+
(request.auto_remediate or request.target_zero_vulns)
|
|
315
|
+
and request.scan
|
|
316
|
+
and scan_result
|
|
317
|
+
and scan_result.total_vulnerabilities > 0
|
|
318
|
+
):
|
|
319
|
+
current_df_path = dockerfile_path
|
|
320
|
+
for round_num in range(1, request.max_remediation_rounds + 1):
|
|
321
|
+
remediated_path, applied_actions = self._derive_and_write_remediated_dockerfile(
|
|
322
|
+
context_path=request.context_path,
|
|
323
|
+
original_dockerfile_path=current_df_path,
|
|
324
|
+
scan_result=scan_result,
|
|
325
|
+
round_num=round_num,
|
|
326
|
+
)
|
|
327
|
+
if not applied_actions:
|
|
328
|
+
logger.info("No further automated remediation actions available.")
|
|
329
|
+
break
|
|
330
|
+
|
|
331
|
+
logger.info(
|
|
332
|
+
f"[Auto-Remediation Round {round_num}] "
|
|
333
|
+
f"Rebuilding with {len(applied_actions)} fix(es)..."
|
|
334
|
+
)
|
|
335
|
+
new_build = self._build_image(
|
|
336
|
+
context_path=request.context_path,
|
|
337
|
+
dockerfile_path=remediated_path,
|
|
338
|
+
tag=request.tag,
|
|
339
|
+
options=BuildOptions(
|
|
340
|
+
tag=request.tag,
|
|
341
|
+
dockerfile_path=remediated_path,
|
|
342
|
+
context_path=request.context_path,
|
|
343
|
+
no_cache=request.no_cache,
|
|
344
|
+
build_args=request.build_args,
|
|
345
|
+
labels=request.labels,
|
|
346
|
+
buildkit=True,
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
if not new_build.success:
|
|
350
|
+
logger.warning(
|
|
351
|
+
f"Remediated build round {round_num} failed: {new_build.error_message}"
|
|
352
|
+
)
|
|
353
|
+
break
|
|
354
|
+
|
|
355
|
+
build_result = new_build
|
|
356
|
+
current_df_path = remediated_path
|
|
357
|
+
prev_scan = scan_result
|
|
358
|
+
new_scan = self._enrich(self._scan_image(request.tag))
|
|
359
|
+
if new_scan:
|
|
360
|
+
scan_result = new_scan
|
|
361
|
+
remediation_history.append(
|
|
362
|
+
{
|
|
363
|
+
"round": round_num,
|
|
364
|
+
"actions": applied_actions,
|
|
365
|
+
"critical_before": prev_scan.critical,
|
|
366
|
+
"critical_after": new_scan.critical,
|
|
367
|
+
"high_before": prev_scan.high,
|
|
368
|
+
"high_after": new_scan.high,
|
|
369
|
+
"total_before": prev_scan.total_vulnerabilities,
|
|
370
|
+
"total_after": new_scan.total_vulnerabilities,
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
if new_scan.total_vulnerabilities == 0:
|
|
374
|
+
logger.info(
|
|
375
|
+
"Success: image achieved ZERO "
|
|
376
|
+
f"vulnerabilities in round {round_num}!"
|
|
377
|
+
)
|
|
378
|
+
break
|
|
379
|
+
|
|
380
|
+
# 7. Verificar thresholds de falha. Entre o limiar da política e o
|
|
381
|
+
# da linha de comando vence o mais estrito: um arquivo no
|
|
382
|
+
# repositório não pode desligar um portão que o pipeline pediu.
|
|
383
|
+
threshold = (
|
|
384
|
+
request.policy.effective_fail_on(request.fail_on or "")
|
|
385
|
+
if request.policy
|
|
386
|
+
else (request.fail_on or "")
|
|
387
|
+
)
|
|
388
|
+
if threshold:
|
|
389
|
+
# Um portão que não pôde ser avaliado não é um portão
|
|
390
|
+
# aprovado. Sem scan, `--fail-on` deixava passar em silêncio
|
|
391
|
+
# qualquer imagem numa máquina sem scanner instalado.
|
|
392
|
+
if scan_result is None:
|
|
393
|
+
return BuildImageResponse(
|
|
394
|
+
success=False,
|
|
395
|
+
image_tag=request.tag,
|
|
396
|
+
image_sha256=build_result.image_sha256,
|
|
397
|
+
validation=validation,
|
|
398
|
+
analysis=validation_result.analysis,
|
|
399
|
+
error=(
|
|
400
|
+
f"--fail-on {threshold} requires a vulnerability scan, "
|
|
401
|
+
"and no scanner (trivy, grype) could be run"
|
|
402
|
+
),
|
|
403
|
+
exit_code=EXIT_ERROR,
|
|
404
|
+
)
|
|
405
|
+
if self._should_fail(scan_result, threshold):
|
|
406
|
+
# A atribuição é calculada aqui e reaproveitada na
|
|
407
|
+
# mensagem: escanear a base duas vezes para dizer a mesma
|
|
408
|
+
# coisa duas vezes seria pagar minutos por nada.
|
|
409
|
+
gate_inheritance = self._attribute_findings(
|
|
410
|
+
request, validation_result, scan_result
|
|
411
|
+
)
|
|
412
|
+
return BuildImageResponse(
|
|
413
|
+
success=False,
|
|
414
|
+
image_tag=request.tag,
|
|
415
|
+
image_sha256=build_result.image_sha256,
|
|
416
|
+
validation=validation,
|
|
417
|
+
analysis=validation_result.analysis,
|
|
418
|
+
error=self._gate_failure_summary(scan_result, threshold, gate_inheritance),
|
|
419
|
+
inheritance=gate_inheritance,
|
|
420
|
+
exit_code=EXIT_POLICY,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# 7a. Cruzar os achados com os da base: a contagem sozinha diz
|
|
424
|
+
# "conserte", e não diz o quê. Roda antes dos portões porque
|
|
425
|
+
# precisa aparecer no relatório mesmo quando o build reprova --
|
|
426
|
+
# é justamente aí que a pergunta "de quem é isso?" é feita.
|
|
427
|
+
inheritance = self._attribute_findings(request, validation_result, scan_result)
|
|
428
|
+
|
|
429
|
+
# 7b. Conferir a política declarada. Antes do push, pelo mesmo
|
|
430
|
+
# motivo do portão de scan: uma imagem que viola a política da
|
|
431
|
+
# organização não é publicada por ter sido construída.
|
|
432
|
+
if request.policy is not None:
|
|
433
|
+
violations = evaluate(
|
|
434
|
+
request.policy,
|
|
435
|
+
self._policy_facts(
|
|
436
|
+
request=request,
|
|
437
|
+
analysis=validation_result.analysis,
|
|
438
|
+
scan_result=scan_result,
|
|
439
|
+
source_before=source_before,
|
|
440
|
+
source_after=source_after,
|
|
441
|
+
image_id=build_result.image_sha256 or "",
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
if violations:
|
|
445
|
+
return BuildImageResponse(
|
|
446
|
+
success=False,
|
|
447
|
+
image_tag=request.tag,
|
|
448
|
+
image_sha256=build_result.image_sha256,
|
|
449
|
+
validation=validation,
|
|
450
|
+
analysis=validation_result.analysis,
|
|
451
|
+
policy_violations=violations,
|
|
452
|
+
inheritance=inheritance,
|
|
453
|
+
error=(f"{len(violations)} .dockerls-policy.yaml rule(s) not met"),
|
|
454
|
+
exit_code=EXIT_POLICY,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# 8. Push, se pedido. Só depois dos portões: publicar uma imagem
|
|
458
|
+
# que reprovou no scan derrota o propósito de ter o portão.
|
|
459
|
+
if request.push:
|
|
460
|
+
# A entrada mudou durante o build: a imagem existe, mas não é a
|
|
461
|
+
# que foi medida. Publicá-la seria distribuir um artefato cuja
|
|
462
|
+
# procedência esta ferramenta acabou de declarar quebrada --
|
|
463
|
+
# a mesma substituição que ela recusa em todo lugar.
|
|
464
|
+
if source_before.dockerfile and source_before != source_after:
|
|
465
|
+
return BuildImageResponse(
|
|
466
|
+
success=False,
|
|
467
|
+
image_tag=request.tag,
|
|
468
|
+
image_sha256=build_result.image_sha256,
|
|
469
|
+
validation=validation,
|
|
470
|
+
analysis=validation_result.analysis,
|
|
471
|
+
error=(
|
|
472
|
+
"publish refused: the Dockerfile or the context changed "
|
|
473
|
+
"during the build, so the image does not correspond to the "
|
|
474
|
+
"input that was measured. Rebuild from a stable tree."
|
|
475
|
+
),
|
|
476
|
+
exit_code=EXIT_POLICY,
|
|
477
|
+
)
|
|
478
|
+
push_error = self._push_image(request.tag, request.push_reference)
|
|
479
|
+
if push_error is not None:
|
|
480
|
+
return BuildImageResponse(
|
|
481
|
+
success=False,
|
|
482
|
+
image_tag=request.tag,
|
|
483
|
+
image_sha256=build_result.image_sha256,
|
|
484
|
+
error=push_error,
|
|
485
|
+
exit_code=EXIT_ERROR,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# 8b. Fechar a cadeia: o digest do manifesto só existe depois do
|
|
489
|
+
# push, e é o único identificador que outra máquina consegue
|
|
490
|
+
# usar para puxar exatamente esta imagem.
|
|
491
|
+
provenance = BuildProvenance(
|
|
492
|
+
tag=request.tag,
|
|
493
|
+
source=source_before,
|
|
494
|
+
source_after=source_after,
|
|
495
|
+
artifact=ArtifactDigests(
|
|
496
|
+
image_id=build_result.image_sha256 or "",
|
|
497
|
+
repo_digest=self._repo_digest(request.push_reference or request.tag),
|
|
498
|
+
published_reference=request.push_reference,
|
|
499
|
+
scanner=scan_result.scan_tool if scan_result else "",
|
|
500
|
+
),
|
|
501
|
+
started_at=started_at,
|
|
502
|
+
finished_at=datetime.now(tz=UTC).isoformat(),
|
|
503
|
+
)
|
|
504
|
+
self._archive_provenance(provenance, request.provenance_path)
|
|
505
|
+
|
|
506
|
+
# 9. Gerar relatório
|
|
507
|
+
report = self._generate_report(
|
|
508
|
+
validation=validation_result,
|
|
509
|
+
build=build_result,
|
|
510
|
+
scan=scan_result,
|
|
511
|
+
image_tag=request.tag,
|
|
512
|
+
dockerfile_path=request.dockerfile_path,
|
|
513
|
+
remediation_history=remediation_history,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
return BuildImageResponse(
|
|
517
|
+
success=True,
|
|
518
|
+
image_tag=request.tag,
|
|
519
|
+
image_sha256=build_result.image_sha256,
|
|
520
|
+
provenance=provenance,
|
|
521
|
+
inheritance=inheritance,
|
|
522
|
+
report=report,
|
|
523
|
+
validation=validation,
|
|
524
|
+
analysis=validation_result.analysis,
|
|
525
|
+
recommendations=list(validation_result.suggestions or []),
|
|
526
|
+
exit_code=EXIT_OK,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
except Exception as e:
|
|
530
|
+
logger.exception(f"Build error: {e}")
|
|
531
|
+
return BuildImageResponse(
|
|
532
|
+
success=False,
|
|
533
|
+
error=str(e),
|
|
534
|
+
exit_code=EXIT_ERROR,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def _validate_dockerfile(self, request: BuildImageRequest) -> AnalyzeDockerfileResponse:
|
|
538
|
+
"""Valida o Dockerfile."""
|
|
539
|
+
from dockerls.application.use_cases.analyze_dockerfile import (
|
|
540
|
+
AnalyzeDockerfileRequest,
|
|
541
|
+
AnalyzeDockerfileUseCase,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
analyze_request = AnalyzeDockerfileRequest(
|
|
545
|
+
dockerfile_path=Path(request.context_path) / request.dockerfile_path,
|
|
546
|
+
include_suggestions=True,
|
|
547
|
+
validate_only=False,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
analyze_use_case = AnalyzeDockerfileUseCase(self.validator, self.template_provider)
|
|
551
|
+
return analyze_use_case.execute(analyze_request)
|
|
552
|
+
|
|
553
|
+
def _format_validation_response(
|
|
554
|
+
self, validation_result: AnalyzeDockerfileResponse
|
|
555
|
+
) -> BuildImageResponse:
|
|
556
|
+
"""Formata resposta apenas de validação.
|
|
557
|
+
|
|
558
|
+
Propaga o `DockerfileValidationResult` inteiro -- checks, contagens e
|
|
559
|
+
score -- porque é ele que a CLI renderiza. Devolver só `success` e
|
|
560
|
+
`exit_code` deixava o comando sem nada para imprimir.
|
|
561
|
+
"""
|
|
562
|
+
validation = validation_result.validation
|
|
563
|
+
if validation is None:
|
|
564
|
+
return BuildImageResponse(
|
|
565
|
+
success=False,
|
|
566
|
+
error="Dockerfile validation produced no result",
|
|
567
|
+
exit_code=EXIT_ERROR,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
analysis = validation_result.analysis
|
|
571
|
+
suggestions = list(validation_result.suggestions or [])
|
|
572
|
+
failed = validation.errors > 0
|
|
573
|
+
|
|
574
|
+
return BuildImageResponse(
|
|
575
|
+
success=not failed,
|
|
576
|
+
report=self._build_validation_report(validation_result, validation, analysis),
|
|
577
|
+
validation=validation,
|
|
578
|
+
analysis=analysis,
|
|
579
|
+
recommendations=suggestions,
|
|
580
|
+
error=self._validation_error_summary(validation) if failed else None,
|
|
581
|
+
exit_code=EXIT_POLICY if failed else EXIT_OK,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
def _format_suggestions_response(
|
|
585
|
+
self, validation_result: AnalyzeDockerfileResponse
|
|
586
|
+
) -> BuildImageResponse:
|
|
587
|
+
"""Formata resposta apenas com sugestões.
|
|
588
|
+
|
|
589
|
+
Carrega também a validação: mostrar as sugestões sem dizer quais
|
|
590
|
+
checks as motivaram não é acionável.
|
|
591
|
+
"""
|
|
592
|
+
validation = validation_result.validation
|
|
593
|
+
analysis = validation_result.analysis
|
|
594
|
+
return BuildImageResponse(
|
|
595
|
+
success=True,
|
|
596
|
+
report=self._build_validation_report(validation_result, validation, analysis)
|
|
597
|
+
if validation is not None
|
|
598
|
+
else None,
|
|
599
|
+
validation=validation,
|
|
600
|
+
analysis=analysis,
|
|
601
|
+
recommendations=list(validation_result.suggestions or []),
|
|
602
|
+
exit_code=EXIT_OK,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
def _build_validation_report(
|
|
606
|
+
self,
|
|
607
|
+
validation_result: AnalyzeDockerfileResponse,
|
|
608
|
+
validation: DockerfileValidationResult,
|
|
609
|
+
analysis: DockerfileAnalysis | None,
|
|
610
|
+
) -> BuildReport:
|
|
611
|
+
"""Relatório de um run que só validou -- sem imagem, sem scan.
|
|
612
|
+
|
|
613
|
+
Existe para que `--ci-mode` emita o mesmo JSON estruturado nos dois
|
|
614
|
+
modos, em vez de um objeto vazio quando nada foi construído.
|
|
615
|
+
"""
|
|
616
|
+
score = (
|
|
617
|
+
analysis.security_score
|
|
618
|
+
if analysis is not None
|
|
619
|
+
else self._calculate_security_score(validation_result, None)
|
|
620
|
+
)
|
|
621
|
+
tier = (
|
|
622
|
+
analysis.security_tier if analysis is not None else self._calculate_security_tier(score)
|
|
623
|
+
)
|
|
624
|
+
return BuildReport(
|
|
625
|
+
build_id=self._new_build_id(validation.dockerfile_path),
|
|
626
|
+
timestamp=datetime.now(tz=UTC).isoformat(),
|
|
627
|
+
image="",
|
|
628
|
+
dockerfile_path=validation.dockerfile_path,
|
|
629
|
+
validation=self._validation_dict(validation),
|
|
630
|
+
security_score=score,
|
|
631
|
+
security_tier=tier,
|
|
632
|
+
recommendations=self._recommendation_dicts(validation_result.suggestions or []),
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
@staticmethod
|
|
636
|
+
def _validation_error_summary(validation: DockerfileValidationResult) -> str:
|
|
637
|
+
"""Resumo textual das regras violadas, para `error` e para logs de CI."""
|
|
638
|
+
failures = [c for c in validation.checks if c.status == ValidationStatus.FAIL]
|
|
639
|
+
header = f"Dockerfile validation failed: {validation.errors} error(s)"
|
|
640
|
+
if not failures:
|
|
641
|
+
return header
|
|
642
|
+
details = "; ".join(
|
|
643
|
+
f"{check.check}"
|
|
644
|
+
f"{f' (line {check.line})' if check.line is not None else ''}: {check.message}"
|
|
645
|
+
for check in failures
|
|
646
|
+
)
|
|
647
|
+
return f"{header} -- {details}"
|
|
648
|
+
|
|
649
|
+
@staticmethod
|
|
650
|
+
def _validation_dict(validation: DockerfileValidationResult) -> dict[str, Any]:
|
|
651
|
+
return {
|
|
652
|
+
"dockerfile_path": validation.dockerfile_path,
|
|
653
|
+
"passed": validation.passed,
|
|
654
|
+
"warnings": validation.warnings,
|
|
655
|
+
"errors": validation.errors,
|
|
656
|
+
# `rule_id`, `references` e `rationale` entram aqui porque este é o
|
|
657
|
+
# arquivo que vai para auditoria. O terminal citava o controle
|
|
658
|
+
# publicado (CIS 4.1, NIST 4.1.2) e o relatório perdia a citação --
|
|
659
|
+
# exatamente onde ela vale mais, que é diante de quem precisa
|
|
660
|
+
# mapear achado para programa de conformidade.
|
|
661
|
+
"checks": [
|
|
662
|
+
{
|
|
663
|
+
"check": check.check,
|
|
664
|
+
"rule_id": check.rule_id,
|
|
665
|
+
"status": check.status.value,
|
|
666
|
+
"message": check.message,
|
|
667
|
+
"severity": check.severity.value,
|
|
668
|
+
"line": check.line,
|
|
669
|
+
"references": check.references,
|
|
670
|
+
"rationale": check.rationale,
|
|
671
|
+
}
|
|
672
|
+
for check in validation.checks
|
|
673
|
+
],
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
@staticmethod
|
|
677
|
+
def _recommendation_dicts(rules: list[HardeningRule]) -> list[dict[str, Any]]:
|
|
678
|
+
return [
|
|
679
|
+
{
|
|
680
|
+
"priority": rule.priority.value,
|
|
681
|
+
"title": rule.title,
|
|
682
|
+
"current": rule.current_state,
|
|
683
|
+
"suggested": rule.suggested_fix,
|
|
684
|
+
"reason": rule.reason,
|
|
685
|
+
}
|
|
686
|
+
for rule in rules
|
|
687
|
+
]
|
|
688
|
+
|
|
689
|
+
@staticmethod
|
|
690
|
+
def _new_build_id(seed: str) -> str:
|
|
691
|
+
stamp = datetime.now(tz=UTC).isoformat()
|
|
692
|
+
return hashlib.sha256(f"{seed}{stamp}".encode()).hexdigest()[:16]
|
|
693
|
+
|
|
694
|
+
def _generate_hardened_dockerfile(self, context_path: str, template: str) -> str:
|
|
695
|
+
"""Gera Dockerfile hardened delegando à infraestrutura.
|
|
696
|
+
|
|
697
|
+
Escrever arquivo é responsabilidade do provider, não do caso de uso:
|
|
698
|
+
aqui só decidimos onde ele deve sair.
|
|
699
|
+
"""
|
|
700
|
+
output_path = Path(context_path) / "Dockerfile.hardened"
|
|
701
|
+
self.template_provider.generate_hardened_dockerfile(
|
|
702
|
+
dockerfile_path=Path(context_path),
|
|
703
|
+
base_image=template,
|
|
704
|
+
output_path=output_path,
|
|
705
|
+
)
|
|
706
|
+
logger.debug(f"Hardened Dockerfile generated: {output_path}")
|
|
707
|
+
return str(output_path)
|
|
708
|
+
|
|
709
|
+
def _derive_and_write_remediated_dockerfile(
|
|
710
|
+
self,
|
|
711
|
+
context_path: str,
|
|
712
|
+
original_dockerfile_path: str,
|
|
713
|
+
scan_result: ScanResult,
|
|
714
|
+
round_num: int,
|
|
715
|
+
) -> tuple[str, list[str]]:
|
|
716
|
+
"""Gera um Dockerfile com patches automáticos de segurança aplicados."""
|
|
717
|
+
full_orig = Path(original_dockerfile_path)
|
|
718
|
+
if not full_orig.is_absolute():
|
|
719
|
+
full_orig = Path(context_path) / original_dockerfile_path
|
|
720
|
+
|
|
721
|
+
if not full_orig.exists():
|
|
722
|
+
return original_dockerfile_path, []
|
|
723
|
+
|
|
724
|
+
content = full_orig.read_text(encoding="utf-8")
|
|
725
|
+
applied: list[str] = []
|
|
726
|
+
lower_content = content.lower()
|
|
727
|
+
|
|
728
|
+
# Identificar distro e tipo de pacote
|
|
729
|
+
is_alpine = "alpine" in lower_content
|
|
730
|
+
is_debian_ubuntu = any(d in lower_content for d in ("debian", "ubuntu", "slim"))
|
|
731
|
+
|
|
732
|
+
# 1. Patch de SO
|
|
733
|
+
os_vulns = [
|
|
734
|
+
v
|
|
735
|
+
for v in scan_result.vulnerabilities
|
|
736
|
+
if v.get("fixed_version")
|
|
737
|
+
and not any(
|
|
738
|
+
lang in (v.get("package") or "").lower() for lang in ("npm", "pip", "node_modules")
|
|
739
|
+
)
|
|
740
|
+
]
|
|
741
|
+
if os_vulns:
|
|
742
|
+
if is_alpine and "apk upgrade" not in lower_content:
|
|
743
|
+
upgrade_cmd = "RUN apk upgrade --no-cache && rm -rf /var/cache/apk/*"
|
|
744
|
+
content = self._insert_instruction(content, upgrade_cmd)
|
|
745
|
+
applied.append(f"Applied Alpine OS security upgrade ({len(os_vulns)} fixable CVEs)")
|
|
746
|
+
elif is_debian_ubuntu and "apt-get upgrade" not in lower_content:
|
|
747
|
+
upgrade_cmd = (
|
|
748
|
+
"RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*"
|
|
749
|
+
)
|
|
750
|
+
content = self._insert_instruction(content, upgrade_cmd)
|
|
751
|
+
applied.append(
|
|
752
|
+
f"Applied Debian/Ubuntu OS security upgrade ({len(os_vulns)} fixable CVEs)"
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
# 2. Patch de npm embutido
|
|
756
|
+
has_npm_vulns = any(
|
|
757
|
+
"npm" in (v.get("package") or "").lower() for v in scan_result.vulnerabilities
|
|
758
|
+
)
|
|
759
|
+
if has_npm_vulns and "npm install -g npm" not in lower_content:
|
|
760
|
+
npm_cmd = "RUN npm install -g npm@latest && npm cache clean --force"
|
|
761
|
+
content = self._insert_instruction(content, npm_cmd)
|
|
762
|
+
# "latest patched release" era uma afirmação sobre o resultado de um
|
|
763
|
+
# comando que ainda não rodou: `npm@latest` traz o que estiver
|
|
764
|
+
# publicado, que pode ou não corrigir o achado. A ação é descrita; o
|
|
765
|
+
# veredito fica com o scan da próxima rodada, que é quem mede.
|
|
766
|
+
applied.append("Inserted `npm install -g npm@latest` (next scan says if it helped)")
|
|
767
|
+
|
|
768
|
+
# 3. Patch de pip embutido
|
|
769
|
+
has_pip_vulns = any(
|
|
770
|
+
(v.get("package") or "").lower() in ("pip", "setuptools", "wheel")
|
|
771
|
+
for v in scan_result.vulnerabilities
|
|
772
|
+
)
|
|
773
|
+
if has_pip_vulns and "pip install --upgrade pip" not in lower_content:
|
|
774
|
+
pip_cmd = "RUN pip install --no-cache-dir --upgrade pip setuptools wheel"
|
|
775
|
+
content = self._insert_instruction(content, pip_cmd)
|
|
776
|
+
# Mesma correção da mensagem do npm: `--upgrade` não é sinônimo de
|
|
777
|
+
# "seguro". Além disso, numa imagem de execução **remover** costuma
|
|
778
|
+
# ser melhor que atualizar -- pip recebe advisory novo com
|
|
779
|
+
# regularidade, então atualizar é imposto recorrente. Isso não é
|
|
780
|
+
# automatizado aqui porque um estágio pode legitimamente precisar
|
|
781
|
+
# de pip depois; a alternativa é dita, e a decisão fica com quem lê.
|
|
782
|
+
applied.append(
|
|
783
|
+
"Inserted `pip install --upgrade pip setuptools wheel` (next scan says if "
|
|
784
|
+
"it helped; in a runtime stage, removing pip usually beats upgrading it)"
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
if not applied:
|
|
788
|
+
return original_dockerfile_path, []
|
|
789
|
+
|
|
790
|
+
remediated_filename = f"Dockerfile.remediated.{round_num}"
|
|
791
|
+
remediated_path = Path(context_path) / remediated_filename
|
|
792
|
+
remediated_path.write_text(content, encoding="utf-8")
|
|
793
|
+
return str(remediated_path), applied
|
|
794
|
+
|
|
795
|
+
@staticmethod
|
|
796
|
+
def _insert_instruction(dockerfile_content: str, instruction: str) -> str:
|
|
797
|
+
"""Insere uma instrução RUN de forma segura antes do USER ou no final do primeiro stage."""
|
|
798
|
+
lines = dockerfile_content.splitlines()
|
|
799
|
+
insert_idx = -1
|
|
800
|
+
for i, line in enumerate(lines):
|
|
801
|
+
stripped = line.strip().upper()
|
|
802
|
+
if stripped.startswith("USER ") and insert_idx == -1:
|
|
803
|
+
insert_idx = i
|
|
804
|
+
break
|
|
805
|
+
|
|
806
|
+
if insert_idx != -1:
|
|
807
|
+
lines.insert(insert_idx, instruction)
|
|
808
|
+
else:
|
|
809
|
+
last_from_idx = 0
|
|
810
|
+
for i, line in enumerate(lines):
|
|
811
|
+
if line.strip().upper().startswith("FROM "):
|
|
812
|
+
last_from_idx = i
|
|
813
|
+
lines.insert(last_from_idx + 1, instruction)
|
|
814
|
+
|
|
815
|
+
return "\n".join(lines) + "\n"
|
|
816
|
+
|
|
817
|
+
def _build_image(
|
|
818
|
+
self,
|
|
819
|
+
context_path: str,
|
|
820
|
+
dockerfile_path: str,
|
|
821
|
+
tag: str,
|
|
822
|
+
options: BuildOptions,
|
|
823
|
+
) -> BuildResult:
|
|
824
|
+
"""Executa o build da imagem Docker."""
|
|
825
|
+
start_time = datetime.now()
|
|
826
|
+
logs: list[str] = []
|
|
827
|
+
warnings: list[str] = []
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
# Comando docker build. O binário é resolvido para caminho
|
|
831
|
+
# absoluto: deixar a escolha para o $PATH é o próprio PATH
|
|
832
|
+
# hijacking que esta ferramenta reporta nas imagens dos outros.
|
|
833
|
+
# BuildKit é ativado via variável de ambiente, não por argumento.
|
|
834
|
+
cmd = [resolve_executable("docker"), "build"]
|
|
835
|
+
|
|
836
|
+
cmd.extend(["-t", tag])
|
|
837
|
+
cmd.extend(["-f", dockerfile_path])
|
|
838
|
+
|
|
839
|
+
if options.no_cache:
|
|
840
|
+
cmd.append("--no-cache")
|
|
841
|
+
|
|
842
|
+
if options.pull:
|
|
843
|
+
cmd.append("--pull")
|
|
844
|
+
|
|
845
|
+
if options.platform:
|
|
846
|
+
cmd.extend(["--platform", options.platform])
|
|
847
|
+
|
|
848
|
+
if options.target:
|
|
849
|
+
cmd.extend(["--target", options.target])
|
|
850
|
+
|
|
851
|
+
if options.build_args:
|
|
852
|
+
for key, value in options.build_args.items():
|
|
853
|
+
cmd.extend(["--build-arg", f"{key}={value}"])
|
|
854
|
+
|
|
855
|
+
if options.labels:
|
|
856
|
+
for key, value in options.labels.items():
|
|
857
|
+
cmd.extend(["--label", f"{key}={value}"])
|
|
858
|
+
|
|
859
|
+
# Adicionar contexto
|
|
860
|
+
cmd.append(context_path)
|
|
861
|
+
|
|
862
|
+
logger.debug(f"Executando comando: {' '.join(cmd)}")
|
|
863
|
+
|
|
864
|
+
# Executar build
|
|
865
|
+
env = {}
|
|
866
|
+
if options.buildkit:
|
|
867
|
+
env["DOCKER_BUILDKIT"] = "1"
|
|
868
|
+
|
|
869
|
+
result = subprocess.run( # nosec B603 # noqa: S603
|
|
870
|
+
cmd,
|
|
871
|
+
capture_output=True,
|
|
872
|
+
text=True,
|
|
873
|
+
timeout=3600, # 1 hora timeout
|
|
874
|
+
env={**os.environ, **env},
|
|
875
|
+
check=False,
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
logs.append(result.stdout)
|
|
879
|
+
if result.stderr:
|
|
880
|
+
logs.append(result.stderr)
|
|
881
|
+
warnings.append(result.stderr)
|
|
882
|
+
|
|
883
|
+
if result.returncode != 0:
|
|
884
|
+
return BuildResult(
|
|
885
|
+
success=False,
|
|
886
|
+
error_message=f"Build failed: {result.stderr}",
|
|
887
|
+
logs=logs,
|
|
888
|
+
warnings=warnings,
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
# Extrair informações da imagem
|
|
892
|
+
image_info = self._get_image_info(tag)
|
|
893
|
+
|
|
894
|
+
end_time = datetime.now()
|
|
895
|
+
build_time = (end_time - start_time).total_seconds()
|
|
896
|
+
|
|
897
|
+
return BuildResult(
|
|
898
|
+
success=True,
|
|
899
|
+
image_tag=tag,
|
|
900
|
+
image_id=image_info.get("Id"),
|
|
901
|
+
image_sha256=image_info.get("Id"),
|
|
902
|
+
build_time_seconds=build_time,
|
|
903
|
+
layers_count=len(image_info.get("RootFS", {}).get("Layers", [])),
|
|
904
|
+
image_size_bytes=image_info.get("Size", 0),
|
|
905
|
+
logs=logs,
|
|
906
|
+
warnings=warnings,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
except ExecutableNotFoundError as e:
|
|
910
|
+
return BuildResult(success=False, error_message=str(e), logs=logs)
|
|
911
|
+
except subprocess.TimeoutExpired:
|
|
912
|
+
return BuildResult(
|
|
913
|
+
success=False,
|
|
914
|
+
error_message="Build timeout (1 hour)",
|
|
915
|
+
logs=logs,
|
|
916
|
+
)
|
|
917
|
+
except Exception as e:
|
|
918
|
+
logger.exception(f"Build error: {e}")
|
|
919
|
+
return BuildResult(
|
|
920
|
+
success=False,
|
|
921
|
+
error_message=str(e),
|
|
922
|
+
logs=logs,
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
def _push_image(self, tag: str, destination: str = "") -> str | None:
|
|
926
|
+
"""Publica a imagem no destino. Devolve a mensagem de erro, ou None.
|
|
927
|
+
|
|
928
|
+
Antes disto o push usava a tag local como está. Numa tag sem host --
|
|
929
|
+
`dockerls:1.5.0`, que é a forma que todo mundo digita -- isso vira uma
|
|
930
|
+
tentativa de publicar em `docker.io/library/dockerls`, recusada com um
|
|
931
|
+
"denied" que não explica nada. Com um destino, a imagem é reetiquetada
|
|
932
|
+
antes: é o passo que faltava entre escolher o registry e publicar nele.
|
|
933
|
+
"""
|
|
934
|
+
target = destination.strip() or tag
|
|
935
|
+
if target != tag:
|
|
936
|
+
retag_error = self._run_docker(
|
|
937
|
+
["tag", tag, target], timeout=60, action=f"Retag to {target}"
|
|
938
|
+
)
|
|
939
|
+
if retag_error is not None:
|
|
940
|
+
return retag_error
|
|
941
|
+
|
|
942
|
+
logger.debug(f"Publishing image: {target}")
|
|
943
|
+
return self._run_docker(["push", target], timeout=1800, action=f"Push de {target}")
|
|
944
|
+
|
|
945
|
+
@staticmethod
|
|
946
|
+
def _run_docker(args: list[str], *, timeout: int, action: str) -> str | None:
|
|
947
|
+
"""Um comando do docker, com o erro em texto em vez de exceção."""
|
|
948
|
+
try:
|
|
949
|
+
result = subprocess.run( # nosec B603 # noqa: S603
|
|
950
|
+
[resolve_executable("docker"), *args],
|
|
951
|
+
capture_output=True,
|
|
952
|
+
text=True,
|
|
953
|
+
timeout=timeout,
|
|
954
|
+
check=False,
|
|
955
|
+
)
|
|
956
|
+
except (ExecutableNotFoundError, OSError, subprocess.SubprocessError) as e:
|
|
957
|
+
return f"{action} falhou: {e}"
|
|
958
|
+
|
|
959
|
+
if result.returncode != 0:
|
|
960
|
+
return f"{action} falhou: {result.stderr.strip()[:500]}"
|
|
961
|
+
return None
|
|
962
|
+
|
|
963
|
+
def _get_image_info(self, tag: str) -> dict[str, Any]:
|
|
964
|
+
"""Obtém informações da imagem construída."""
|
|
965
|
+
try:
|
|
966
|
+
result = subprocess.run( # nosec B603 # noqa: S603
|
|
967
|
+
[resolve_executable("docker"), "image", "inspect", tag],
|
|
968
|
+
capture_output=True,
|
|
969
|
+
text=True,
|
|
970
|
+
timeout=30,
|
|
971
|
+
check=False,
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
if result.returncode == 0:
|
|
975
|
+
images = json.loads(result.stdout)
|
|
976
|
+
if images:
|
|
977
|
+
info: dict[str, Any] = images[0]
|
|
978
|
+
return info
|
|
979
|
+
|
|
980
|
+
return {}
|
|
981
|
+
except Exception as e:
|
|
982
|
+
logger.warning(f"Could not read the image info: {e}")
|
|
983
|
+
return {}
|
|
984
|
+
|
|
985
|
+
def _scan_image(self, image_tag: str) -> ScanResult | None:
|
|
986
|
+
"""Executa scan de segurança na imagem."""
|
|
987
|
+
logger.info(f"Starting the image scan: {image_tag}")
|
|
988
|
+
start_time = datetime.now()
|
|
989
|
+
|
|
990
|
+
try:
|
|
991
|
+
# Tentar usar Trivy
|
|
992
|
+
result = subprocess.run( # nosec B603 # noqa: S603
|
|
993
|
+
[
|
|
994
|
+
resolve_executable("trivy"),
|
|
995
|
+
"image",
|
|
996
|
+
"--format",
|
|
997
|
+
"json",
|
|
998
|
+
"--severity",
|
|
999
|
+
"CRITICAL,HIGH,MEDIUM,LOW,UNKNOWN",
|
|
1000
|
+
image_tag,
|
|
1001
|
+
],
|
|
1002
|
+
capture_output=True,
|
|
1003
|
+
text=True,
|
|
1004
|
+
timeout=600, # 10 minutos
|
|
1005
|
+
check=False,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
if result.returncode == 0 and result.stdout:
|
|
1009
|
+
scan = self._parse_trivy_scan(json.loads(result.stdout))
|
|
1010
|
+
scan.scan_time_seconds = (datetime.now() - start_time).total_seconds()
|
|
1011
|
+
return scan
|
|
1012
|
+
logger.warning(f"Trivy falhou (exit {result.returncode}), tentando Grype...")
|
|
1013
|
+
|
|
1014
|
+
except ExecutableNotFoundError:
|
|
1015
|
+
logger.warning("Trivy not found, trying Grype...")
|
|
1016
|
+
except Exception as e:
|
|
1017
|
+
logger.warning(f"Trivy scan error: {e}")
|
|
1018
|
+
|
|
1019
|
+
# Fallback: tentar Grype
|
|
1020
|
+
try:
|
|
1021
|
+
result = subprocess.run( # nosec B603 # noqa: S603
|
|
1022
|
+
[
|
|
1023
|
+
resolve_executable("grype"),
|
|
1024
|
+
image_tag,
|
|
1025
|
+
"-o",
|
|
1026
|
+
"json",
|
|
1027
|
+
],
|
|
1028
|
+
capture_output=True,
|
|
1029
|
+
text=True,
|
|
1030
|
+
timeout=600,
|
|
1031
|
+
check=False,
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
if result.returncode == 0 and result.stdout:
|
|
1035
|
+
scan = self._parse_grype_scan(json.loads(result.stdout))
|
|
1036
|
+
scan.scan_time_seconds = (datetime.now() - start_time).total_seconds()
|
|
1037
|
+
return scan
|
|
1038
|
+
|
|
1039
|
+
except Exception as e:
|
|
1040
|
+
logger.warning(f"Grype failed as well: {e}")
|
|
1041
|
+
|
|
1042
|
+
logger.warning("No scanner available")
|
|
1043
|
+
return None
|
|
1044
|
+
|
|
1045
|
+
@staticmethod
|
|
1046
|
+
def _parse_trivy_scan(data: dict[str, Any]) -> ScanResult:
|
|
1047
|
+
"""Converte o JSON do Trivy em contagens por severidade."""
|
|
1048
|
+
counts = dict.fromkeys(("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"), 0)
|
|
1049
|
+
fixable = 0
|
|
1050
|
+
vulnerabilities: list[dict[str, Any]] = []
|
|
1051
|
+
|
|
1052
|
+
for finding in data.get("Results", []):
|
|
1053
|
+
for vuln in finding.get("Vulnerabilities", []):
|
|
1054
|
+
severity = str(vuln.get("Severity", "UNKNOWN")).upper()
|
|
1055
|
+
counts[severity if severity in counts else "UNKNOWN"] += 1
|
|
1056
|
+
if vuln.get("FixedVersion"):
|
|
1057
|
+
fixable += 1
|
|
1058
|
+
vulnerabilities.append(
|
|
1059
|
+
{
|
|
1060
|
+
"cve_id": vuln.get("VulnerabilityID"),
|
|
1061
|
+
"package": vuln.get("PkgName"),
|
|
1062
|
+
"severity": severity,
|
|
1063
|
+
"installed_version": vuln.get("InstalledVersion"),
|
|
1064
|
+
"fixed_version": vuln.get("FixedVersion"),
|
|
1065
|
+
}
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
return BuildImageUseCase._scan_result(counts, fixable, vulnerabilities, "trivy")
|
|
1069
|
+
|
|
1070
|
+
@staticmethod
|
|
1071
|
+
def _parse_grype_scan(data: dict[str, Any]) -> ScanResult:
|
|
1072
|
+
"""Converte o JSON do Grype em contagens por severidade.
|
|
1073
|
+
|
|
1074
|
+
Este parser não existia: o fallback devolvia um `ScanResult()` zerado
|
|
1075
|
+
com um comentário "parse similar ao Trivy...". Numa máquina só com
|
|
1076
|
+
Grype, todo build era reportado com zero vulnerabilidades e
|
|
1077
|
+
`--fail-on critical` nunca reprovava nada.
|
|
1078
|
+
"""
|
|
1079
|
+
counts = dict.fromkeys(("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"), 0)
|
|
1080
|
+
fixable = 0
|
|
1081
|
+
vulnerabilities: list[dict[str, Any]] = []
|
|
1082
|
+
|
|
1083
|
+
for match in data.get("matches", []):
|
|
1084
|
+
vuln = match.get("vulnerability", {})
|
|
1085
|
+
severity = str(vuln.get("severity", "UNKNOWN")).upper()
|
|
1086
|
+
# Grype tem uma faixa a mais que o Trivy; sem isso ela cairia em
|
|
1087
|
+
# UNKNOWN e sumiria da contagem de LOW.
|
|
1088
|
+
if severity == "NEGLIGIBLE":
|
|
1089
|
+
severity = "LOW"
|
|
1090
|
+
counts[severity if severity in counts else "UNKNOWN"] += 1
|
|
1091
|
+
|
|
1092
|
+
artifact = match.get("artifact", {})
|
|
1093
|
+
fixed_versions = vuln.get("fix", {}).get("versions", []) or []
|
|
1094
|
+
if fixed_versions:
|
|
1095
|
+
fixable += 1
|
|
1096
|
+
vulnerabilities.append(
|
|
1097
|
+
{
|
|
1098
|
+
"cve_id": vuln.get("id"),
|
|
1099
|
+
"package": artifact.get("name"),
|
|
1100
|
+
"severity": severity,
|
|
1101
|
+
"installed_version": artifact.get("version"),
|
|
1102
|
+
"fixed_version": fixed_versions[0] if fixed_versions else None,
|
|
1103
|
+
}
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
return BuildImageUseCase._scan_result(counts, fixable, vulnerabilities, "grype")
|
|
1107
|
+
|
|
1108
|
+
@staticmethod
|
|
1109
|
+
def _scan_result(
|
|
1110
|
+
counts: dict[str, int],
|
|
1111
|
+
fixable: int,
|
|
1112
|
+
vulnerabilities: list[dict[str, Any]],
|
|
1113
|
+
tool: str,
|
|
1114
|
+
) -> ScanResult:
|
|
1115
|
+
return ScanResult(
|
|
1116
|
+
critical=counts["CRITICAL"],
|
|
1117
|
+
high=counts["HIGH"],
|
|
1118
|
+
medium=counts["MEDIUM"],
|
|
1119
|
+
low=counts["LOW"],
|
|
1120
|
+
unknown=counts["UNKNOWN"],
|
|
1121
|
+
total_vulnerabilities=sum(counts.values()),
|
|
1122
|
+
fixable=fixable,
|
|
1123
|
+
scan_tool=tool,
|
|
1124
|
+
vulnerabilities=BuildImageUseCase._worst_first(vulnerabilities),
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
#: Achados mantidos na amostra do relatório. As contagens acima são
|
|
1128
|
+
#: completas; esta lista existe para o relatório não carregar milhares de
|
|
1129
|
+
#: entradas.
|
|
1130
|
+
MAX_RETAINED_VULNERABILITIES = 100
|
|
1131
|
+
|
|
1132
|
+
@staticmethod
|
|
1133
|
+
def _worst_first(vulnerabilities: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1134
|
+
"""Ordena por severidade antes de cortar a amostra.
|
|
1135
|
+
|
|
1136
|
+
O corte era `vulnerabilities[:100]` na ordem em que o scanner
|
|
1137
|
+
devolveu, que é ordem de pacote e não de gravidade. Numa imagem com
|
|
1138
|
+
mais de cem achados, as CRITICAL podiam cair inteiramente fora da
|
|
1139
|
+
amostra -- e foi o que aconteceu: o portão reprovava (as *contagens*
|
|
1140
|
+
estavam certas) enquanto o resumo, que lê a amostra, anunciava
|
|
1141
|
+
"0 finding(s) at or above CRITICAL". O leitor recebia uma reprovação
|
|
1142
|
+
que se contradizia e nenhum CVE para investigar.
|
|
1143
|
+
|
|
1144
|
+
Ordenar antes de cortar garante que o que decide o portão é o que
|
|
1145
|
+
sobrevive na amostra.
|
|
1146
|
+
"""
|
|
1147
|
+
order = {level: index for index, level in enumerate(("CRITICAL", "HIGH", "MEDIUM", "LOW"))}
|
|
1148
|
+
ranked = sorted(
|
|
1149
|
+
vulnerabilities,
|
|
1150
|
+
key=lambda v: order.get(str(v.get("severity", "")).upper(), len(order)),
|
|
1151
|
+
)
|
|
1152
|
+
return ranked[: BuildImageUseCase.MAX_RETAINED_VULNERABILITIES]
|
|
1153
|
+
|
|
1154
|
+
#: Limiares aceitos por `--fail-on`, do mais severo para o mais brando.
|
|
1155
|
+
#: Cada um reprova também tudo que for pior que ele.
|
|
1156
|
+
FAIL_ON_THRESHOLDS = ("critical", "high", "medium", "low")
|
|
1157
|
+
|
|
1158
|
+
def _enrich(self, scan_result: ScanResult | None) -> ScanResult | None:
|
|
1159
|
+
"""Anota os achados com CISA KEV e EPSS, quando há quem consultar.
|
|
1160
|
+
|
|
1161
|
+
O `build` escaneia direto com Trivy/Grype, fora do pipeline de
|
|
1162
|
+
`recommend` que faz esse enriquecimento -- então os portões `kev` e
|
|
1163
|
+
`epss` não tinham dado nenhum para olhar. Sem isto eles não
|
|
1164
|
+
reprovariam nunca, o que é a pior falha possível num portão de
|
|
1165
|
+
segurança: a que não aparece.
|
|
1166
|
+
|
|
1167
|
+
Como no `recommend`, só CRITICAL e HIGH são consultados. O que fica
|
|
1168
|
+
de fora permanece UNKNOWN por construção -- e isso é correto: não
|
|
1169
|
+
foi consultado.
|
|
1170
|
+
|
|
1171
|
+
O que **não** acontece aqui: marcar um achado como não explorado
|
|
1172
|
+
porque o catálogo não respondeu. Um feed fora do ar deixa tudo em
|
|
1173
|
+
UNKNOWN, e é o portão que decide o que fazer com isso.
|
|
1174
|
+
"""
|
|
1175
|
+
if scan_result is None or self.threat_intel is None:
|
|
1176
|
+
return scan_result
|
|
1177
|
+
notable = [
|
|
1178
|
+
v
|
|
1179
|
+
for v in scan_result.vulnerabilities
|
|
1180
|
+
if str(v.get("severity") or "").upper() in ("CRITICAL", "HIGH") and v.get("cve_id")
|
|
1181
|
+
]
|
|
1182
|
+
if not notable:
|
|
1183
|
+
return scan_result
|
|
1184
|
+
|
|
1185
|
+
ids = [str(v["cve_id"]) for v in notable]
|
|
1186
|
+
try:
|
|
1187
|
+
kev_ids, epss = asyncio.run(_lookup_threat_intel(self.threat_intel, ids))
|
|
1188
|
+
except (OSError, RuntimeError) as e:
|
|
1189
|
+
# Consulta que não aconteceu deixa tudo UNKNOWN, e o portão diz
|
|
1190
|
+
# que não pôde avaliar. Derrubar o build aqui trocaria uma
|
|
1191
|
+
# medição ausente por um erro técnico.
|
|
1192
|
+
logger.warning(f"Threat intelligence lookup failed: {e}")
|
|
1193
|
+
return scan_result
|
|
1194
|
+
|
|
1195
|
+
kev_answered = self.threat_intel.kev_available
|
|
1196
|
+
for v in notable:
|
|
1197
|
+
cve = str(v["cve_id"])
|
|
1198
|
+
if kev_answered is True:
|
|
1199
|
+
v["kev"] = str(Tristate.TRUE if cve in kev_ids else Tristate.FALSE)
|
|
1200
|
+
if cve in epss:
|
|
1201
|
+
v["epss"] = epss[cve]
|
|
1202
|
+
return scan_result
|
|
1203
|
+
|
|
1204
|
+
def _findings(self, scan_result: ScanResult) -> list[Finding]:
|
|
1205
|
+
"""Os achados retidos, na forma que o portão entende.
|
|
1206
|
+
|
|
1207
|
+
A amostra, e não o scan inteiro: o relatório retém um número
|
|
1208
|
+
limitado de achados, e é dela que saem os nomes citados na
|
|
1209
|
+
mensagem. A **contagem** que reprova vem separada, do scan
|
|
1210
|
+
completo -- ver `Gate.evaluate`.
|
|
1211
|
+
"""
|
|
1212
|
+
return [
|
|
1213
|
+
Finding(
|
|
1214
|
+
cve_id=str(v.get("cve_id") or ""),
|
|
1215
|
+
severity=str(v.get("severity") or ""),
|
|
1216
|
+
kev=_tristate(v.get("kev")),
|
|
1217
|
+
epss=_as_probability(v.get("epss")),
|
|
1218
|
+
package=str(v.get("package") or ""),
|
|
1219
|
+
fixed_version=str(v.get("fixed_version") or ""),
|
|
1220
|
+
)
|
|
1221
|
+
for v in scan_result.vulnerabilities
|
|
1222
|
+
]
|
|
1223
|
+
|
|
1224
|
+
@staticmethod
|
|
1225
|
+
def _severity_counts(scan_result: ScanResult) -> dict[str, int]:
|
|
1226
|
+
return {
|
|
1227
|
+
"critical": scan_result.critical,
|
|
1228
|
+
"high": scan_result.high,
|
|
1229
|
+
"medium": scan_result.medium,
|
|
1230
|
+
"low": scan_result.low,
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
def _gate_verdicts(self, scan_result: ScanResult, threshold: str) -> tuple[GateVerdict, ...]:
|
|
1234
|
+
"""Os portões que **não** passaram. Vazio significa aprovado.
|
|
1235
|
+
|
|
1236
|
+
Um limiar desconhecido é recusado aqui, e não ignorado: um valor que
|
|
1237
|
+
caísse num `return False` seria um portão que nunca reprova, em
|
|
1238
|
+
silêncio -- e esse bug já existiu neste arquivo com
|
|
1239
|
+
`--fail-on medium`.
|
|
1240
|
+
"""
|
|
1241
|
+
gates = GateSet.parse(threshold)
|
|
1242
|
+
return gates.evaluate(self._findings(scan_result), self._severity_counts(scan_result))
|
|
1243
|
+
|
|
1244
|
+
def _should_fail(self, scan_result: ScanResult, threshold: str) -> bool:
|
|
1245
|
+
"""Se o build não deve prosseguir. Predicado sobre `_gate_verdicts`.
|
|
1246
|
+
|
|
1247
|
+
Um `UNMEASURED` conta como falha: quem pediu `--fail-on kev` pediu
|
|
1248
|
+
que a exploração fosse conferida, e não conseguir conferir deixa a
|
|
1249
|
+
pergunta sem resposta. Aprovar aí gastaria a ausência de medição
|
|
1250
|
+
como tranquilidade -- e, pior, desligaria um portão de segurança em
|
|
1251
|
+
silêncio numa oscilação de rede.
|
|
1252
|
+
"""
|
|
1253
|
+
return bool(self._gate_verdicts(scan_result, threshold))
|
|
1254
|
+
|
|
1255
|
+
def _gate_failure_summary(
|
|
1256
|
+
self,
|
|
1257
|
+
scan_result: ScanResult,
|
|
1258
|
+
threshold: str,
|
|
1259
|
+
inheritance: InheritanceReport | None = None,
|
|
1260
|
+
) -> str:
|
|
1261
|
+
"""Nomeia o que disparou cada portão.
|
|
1262
|
+
|
|
1263
|
+
"Vulnerabilities exceed threshold (critical)" obrigava quem lê o log
|
|
1264
|
+
do CI a reabrir o relatório para descobrir *o quê*. Cada portão diz
|
|
1265
|
+
agora qual achado o disparou, com pacote e versão de correção.
|
|
1266
|
+
|
|
1267
|
+
Quando a atribuição rodou, a linha também diz **de onde** os achados
|
|
1268
|
+
vieram. É a informação mais cara de obter e a mais barata de mostrar
|
|
1269
|
+
aqui: quem lê o log está decidindo, naquele segundo, se mexe no
|
|
1270
|
+
Dockerfile ou na base -- e sem isso a decisão é um palpite.
|
|
1271
|
+
|
|
1272
|
+
Um portão `UNMEASURED` sai com palavras diferentes de propósito: ele
|
|
1273
|
+
não é um veredito sobre a imagem, é a constatação de que a pergunta
|
|
1274
|
+
ficou sem resposta, e confundir os dois faria o log do CI acusar uma
|
|
1275
|
+
imagem por uma falha de rede.
|
|
1276
|
+
"""
|
|
1277
|
+
parts: list[str] = []
|
|
1278
|
+
for verdict in self._gate_verdicts(scan_result, threshold):
|
|
1279
|
+
if verdict.outcome is GateOutcome.UNMEASURED:
|
|
1280
|
+
parts.append(f"Gate not evaluated ({verdict.kind.value.lower()}): {verdict.reason}")
|
|
1281
|
+
continue
|
|
1282
|
+
header = f"Gate failed ({verdict.kind.value.lower()}): {verdict.reason}"
|
|
1283
|
+
if not verdict.offenders:
|
|
1284
|
+
parts.append(
|
|
1285
|
+
f"{header}{_origin_hint(inheritance)} "
|
|
1286
|
+
"(not kept in the report sample; run the scanner for the full list)"
|
|
1287
|
+
)
|
|
1288
|
+
continue
|
|
1289
|
+
listed = "; ".join(
|
|
1290
|
+
f"{f.cve_id or '?'} ({f.severity}) in {f.package or '?'}".strip()
|
|
1291
|
+
+ (f" -> {f.fixed_version}" if f.fixed_version else " (no fix)")
|
|
1292
|
+
for f in verdict.offenders[:10]
|
|
1293
|
+
)
|
|
1294
|
+
more = (
|
|
1295
|
+
f"; ... and {len(verdict.offenders) - 10} more"
|
|
1296
|
+
if len(verdict.offenders) > 10
|
|
1297
|
+
else ""
|
|
1298
|
+
)
|
|
1299
|
+
parts.append(f"{header}{_origin_hint(inheritance)} -- {listed}{more}")
|
|
1300
|
+
return f"[--fail-on {threshold}] " + " | ".join(parts)
|
|
1301
|
+
|
|
1302
|
+
def _preflight(
|
|
1303
|
+
self, request: BuildImageRequest, validation: AnalyzeDockerfileResponse
|
|
1304
|
+
) -> list[PolicyViolation]:
|
|
1305
|
+
"""As regras que já dá para reprovar sem construir nada.
|
|
1306
|
+
|
|
1307
|
+
Descobrir um rótulo obrigatório faltando depois de dez minutos de build
|
|
1308
|
+
e um scan é o tipo de atrito que faz as pessoas pararem de rodar o
|
|
1309
|
+
portão. As regras que dependem de medição continuam para depois: elas
|
|
1310
|
+
não são consideradas cumpridas aqui, apenas não são conferíveis.
|
|
1311
|
+
"""
|
|
1312
|
+
if request.policy is None:
|
|
1313
|
+
return []
|
|
1314
|
+
return evaluate(
|
|
1315
|
+
request.policy.static_subset(),
|
|
1316
|
+
PolicyFacts(
|
|
1317
|
+
bases=self._base_facts(request),
|
|
1318
|
+
labels=dict(request.labels or {}),
|
|
1319
|
+
nonroot=self._nonroot_state(validation.analysis),
|
|
1320
|
+
),
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
def _base_facts(self, request: BuildImageRequest) -> tuple[BaseFact, ...]:
|
|
1324
|
+
"""As bases declaradas, lidas do arquivo com expansão de `ARG`."""
|
|
1325
|
+
path = Path(request.context_path) / request.dockerfile_path
|
|
1326
|
+
try:
|
|
1327
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
1328
|
+
except OSError as e:
|
|
1329
|
+
logger.debug(f"Could not re-read {path}: {e}")
|
|
1330
|
+
return ()
|
|
1331
|
+
return tuple(
|
|
1332
|
+
BaseFact(
|
|
1333
|
+
reference=base.reference,
|
|
1334
|
+
registry=registry_host_of(base.name),
|
|
1335
|
+
pinned=base.is_pinned,
|
|
1336
|
+
)
|
|
1337
|
+
for base in parse_bases(content)
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
def _attribute_findings(
|
|
1341
|
+
self,
|
|
1342
|
+
request: BuildImageRequest,
|
|
1343
|
+
validation: AnalyzeDockerfileResponse,
|
|
1344
|
+
scan_result: ScanResult | None,
|
|
1345
|
+
) -> InheritanceReport | None:
|
|
1346
|
+
"""De quem é cada CVE: da base declarada, ou das camadas deste build.
|
|
1347
|
+
|
|
1348
|
+
Um relatório que diz "47 vulnerabilidades" manda consertar sem dizer o
|
|
1349
|
+
quê. A resposta exige um segundo scan -- o da base -- e por isso é
|
|
1350
|
+
escolha explícita: dobrar o tempo de portão por padrão faria as pessoas
|
|
1351
|
+
desligarem o portão.
|
|
1352
|
+
|
|
1353
|
+
Devolve `None` quando ninguém pediu, e um relatório `UNAVAILABLE` com o
|
|
1354
|
+
motivo quando pediram e não deu. As duas coisas são diferentes de "tudo
|
|
1355
|
+
é seu" e de "tudo é herdado", que seriam as duas formas de transformar
|
|
1356
|
+
ausência de medição em acusação.
|
|
1357
|
+
"""
|
|
1358
|
+
if not request.attribute_findings:
|
|
1359
|
+
return None
|
|
1360
|
+
if scan_result is None:
|
|
1361
|
+
return unavailable("", "the built image could not be scanned")
|
|
1362
|
+
|
|
1363
|
+
analysis = validation.analysis
|
|
1364
|
+
base_reference = (analysis.info.final_base_image or "") if analysis else ""
|
|
1365
|
+
if not base_reference:
|
|
1366
|
+
return unavailable(
|
|
1367
|
+
"",
|
|
1368
|
+
"the final stage base could not be determined from the Dockerfile",
|
|
1369
|
+
)
|
|
1370
|
+
if base_reference.lower() == "scratch":
|
|
1371
|
+
# `scratch` não é uma imagem: não há o que escanear, e tudo que a
|
|
1372
|
+
# imagem carrega veio das camadas deste build. Dizer isso é
|
|
1373
|
+
# atribuição, não omissão.
|
|
1374
|
+
return attribute(
|
|
1375
|
+
_as_vulnerabilities(scan_result.vulnerabilities), [], base_reference=base_reference
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
logger.info(f"Scanning the base {base_reference} to attribute the findings")
|
|
1379
|
+
base_scan = self._scan_image(base_reference)
|
|
1380
|
+
if base_scan is None:
|
|
1381
|
+
return unavailable(
|
|
1382
|
+
base_reference,
|
|
1383
|
+
f"the base {base_reference} could not be scanned",
|
|
1384
|
+
)
|
|
1385
|
+
return attribute(
|
|
1386
|
+
_as_vulnerabilities(scan_result.vulnerabilities),
|
|
1387
|
+
_as_vulnerabilities(base_scan.vulnerabilities),
|
|
1388
|
+
base_reference=base_reference,
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
def _policy_facts(
|
|
1392
|
+
self,
|
|
1393
|
+
*,
|
|
1394
|
+
request: BuildImageRequest,
|
|
1395
|
+
analysis: DockerfileAnalysis | None,
|
|
1396
|
+
scan_result: ScanResult | None,
|
|
1397
|
+
source_before: SourceDigests,
|
|
1398
|
+
source_after: SourceDigests,
|
|
1399
|
+
image_id: str,
|
|
1400
|
+
) -> PolicyFacts:
|
|
1401
|
+
"""Os fatos medidos neste build, no formato que a política avalia.
|
|
1402
|
+
|
|
1403
|
+
Nada aqui infere: cada campo ou vem de uma medição que aconteceu ou
|
|
1404
|
+
fica no valor que significa "não medido". É o que faz a política
|
|
1405
|
+
reprovar por ausência de prova em vez de aprovar por falta de sinal.
|
|
1406
|
+
"""
|
|
1407
|
+
counts: dict[str, int] = {}
|
|
1408
|
+
if scan_result is not None:
|
|
1409
|
+
counts = {
|
|
1410
|
+
"critical": scan_result.critical,
|
|
1411
|
+
"high": scan_result.high,
|
|
1412
|
+
"medium": scan_result.medium,
|
|
1413
|
+
"low": scan_result.low,
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
bases = tuple(
|
|
1417
|
+
BaseFact(
|
|
1418
|
+
reference=reference,
|
|
1419
|
+
registry=registry_host_of(reference),
|
|
1420
|
+
pinned=bool(digest) or "@sha256:" in reference,
|
|
1421
|
+
)
|
|
1422
|
+
for reference, digest in source_before.base_images.items()
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
# A procedência é recalculada aqui e não lida do documento: o registro
|
|
1426
|
+
# final só existe depois do push, e a política precisa decidir antes.
|
|
1427
|
+
parcial = BuildProvenance(
|
|
1428
|
+
tag=request.tag,
|
|
1429
|
+
source=source_before,
|
|
1430
|
+
source_after=source_after,
|
|
1431
|
+
artifact=ArtifactDigests(image_id=image_id),
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
return PolicyFacts(
|
|
1435
|
+
scan_ran=scan_result is not None,
|
|
1436
|
+
severity_counts=counts,
|
|
1437
|
+
bases=bases,
|
|
1438
|
+
labels=dict(request.labels or {}),
|
|
1439
|
+
nonroot=self._nonroot_state(analysis),
|
|
1440
|
+
provenance_status=str(parcial.status),
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1443
|
+
@staticmethod
|
|
1444
|
+
def _nonroot_state(analysis: DockerfileAnalysis | None) -> Tristate:
|
|
1445
|
+
"""Se a imagem roda sem privilégio, segundo o que a validação mediu.
|
|
1446
|
+
|
|
1447
|
+
A ausência da checagem é `UNKNOWN`, e não `FALSE`: não ter medido não
|
|
1448
|
+
é ter medido e reprovado, e a política distingue os dois na mensagem.
|
|
1449
|
+
O veredito vem do DF002, que já sabe que `USER 0` e `USER 0:0` são root
|
|
1450
|
+
tanto quanto `USER root` -- reimplementar a leitura aqui seria manter
|
|
1451
|
+
duas definições de "sem privilégio" que divergiriam na primeira
|
|
1452
|
+
correção.
|
|
1453
|
+
"""
|
|
1454
|
+
if analysis is None:
|
|
1455
|
+
return Tristate.UNKNOWN
|
|
1456
|
+
for check in analysis.validation.checks:
|
|
1457
|
+
if check.rule_id == "DF002":
|
|
1458
|
+
return Tristate.of(check.status is ValidationStatus.PASS)
|
|
1459
|
+
return Tristate.UNKNOWN
|
|
1460
|
+
|
|
1461
|
+
def _digest_source(self, context_path: str, dockerfile_path: str) -> SourceDigests:
|
|
1462
|
+
"""Digere a entrada do build: Dockerfile, contexto, bases e revisão.
|
|
1463
|
+
|
|
1464
|
+
Nada aqui é fatal. Um contexto que não pôde ser digerido devolve
|
|
1465
|
+
campos vazios, e a procedência se declara `INCOMPLETE` -- que é a
|
|
1466
|
+
ausência de prova, não uma acusação, e é muito diferente de fingir
|
|
1467
|
+
que a cadeia fechou.
|
|
1468
|
+
"""
|
|
1469
|
+
root = Path(context_path)
|
|
1470
|
+
dockerfile = (
|
|
1471
|
+
root / dockerfile_path
|
|
1472
|
+
if not Path(dockerfile_path).is_absolute()
|
|
1473
|
+
else Path(dockerfile_path)
|
|
1474
|
+
)
|
|
1475
|
+
|
|
1476
|
+
dockerfile_digest = ""
|
|
1477
|
+
base_images: dict[str, str] = {}
|
|
1478
|
+
if dockerfile.is_file():
|
|
1479
|
+
try:
|
|
1480
|
+
dockerfile_digest = hash_file(dockerfile)
|
|
1481
|
+
base_images = self._declared_bases(dockerfile)
|
|
1482
|
+
except OSError as e:
|
|
1483
|
+
logger.warning(f"Could not digest {dockerfile}: {e}")
|
|
1484
|
+
|
|
1485
|
+
context_digest, counted = "", 0
|
|
1486
|
+
try:
|
|
1487
|
+
context_digest, counted = hash_context(root)
|
|
1488
|
+
except (OSError, ContextTooLargeError) as e:
|
|
1489
|
+
logger.warning(f"Could not digest the context {root}: {e}")
|
|
1490
|
+
|
|
1491
|
+
revision, dirty = self._git_state(root)
|
|
1492
|
+
return SourceDigests(
|
|
1493
|
+
dockerfile=dockerfile_digest,
|
|
1494
|
+
context=context_digest,
|
|
1495
|
+
context_files=counted,
|
|
1496
|
+
base_images=base_images,
|
|
1497
|
+
git_revision=revision,
|
|
1498
|
+
git_dirty=dirty,
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
@staticmethod
|
|
1502
|
+
def _declared_bases(dockerfile: Path) -> dict[str, str]:
|
|
1503
|
+
"""Cada `FROM` e o digest que ele fixa, quando fixa.
|
|
1504
|
+
|
|
1505
|
+
Uma base sem digest é uma tag móvel, e registrar isso vale mais do
|
|
1506
|
+
que omitir: é exatamente a diferença entre um build reproduzível e um
|
|
1507
|
+
que depende do dia.
|
|
1508
|
+
"""
|
|
1509
|
+
bases: dict[str, str] = {}
|
|
1510
|
+
try:
|
|
1511
|
+
content = dockerfile.read_text(encoding="utf-8", errors="replace")
|
|
1512
|
+
except OSError:
|
|
1513
|
+
return bases
|
|
1514
|
+
for line in content.splitlines():
|
|
1515
|
+
stripped = line.strip()
|
|
1516
|
+
if not stripped.upper().startswith("FROM "):
|
|
1517
|
+
continue
|
|
1518
|
+
reference = stripped.split()[1]
|
|
1519
|
+
_, separator, digest = reference.partition("@")
|
|
1520
|
+
bases[reference] = digest if separator else ""
|
|
1521
|
+
return bases
|
|
1522
|
+
|
|
1523
|
+
@staticmethod
|
|
1524
|
+
def _git_state(root: Path) -> tuple[str, bool]:
|
|
1525
|
+
"""Commit e limpeza da árvore. Um commit sozinho mentiria sobre o que
|
|
1526
|
+
gerou a imagem se houvesse alteração não commitada."""
|
|
1527
|
+
try:
|
|
1528
|
+
revision = subprocess.run( # nosec B603 # noqa: S603
|
|
1529
|
+
[resolve_executable("git"), "-C", str(root), "rev-parse", "HEAD"],
|
|
1530
|
+
capture_output=True,
|
|
1531
|
+
text=True,
|
|
1532
|
+
timeout=15,
|
|
1533
|
+
check=False,
|
|
1534
|
+
)
|
|
1535
|
+
if revision.returncode != 0:
|
|
1536
|
+
return "", False
|
|
1537
|
+
status = subprocess.run( # nosec B603 # noqa: S603
|
|
1538
|
+
[resolve_executable("git"), "-C", str(root), "status", "--porcelain"],
|
|
1539
|
+
capture_output=True,
|
|
1540
|
+
text=True,
|
|
1541
|
+
timeout=15,
|
|
1542
|
+
check=False,
|
|
1543
|
+
)
|
|
1544
|
+
return revision.stdout.strip(), bool(status.stdout.strip())
|
|
1545
|
+
except (ExecutableNotFoundError, OSError, subprocess.SubprocessError):
|
|
1546
|
+
return "", False
|
|
1547
|
+
|
|
1548
|
+
def _repo_digest(self, reference: str) -> str:
|
|
1549
|
+
"""O digest do manifesto, que só existe depois de um push."""
|
|
1550
|
+
info = self._get_image_info(reference)
|
|
1551
|
+
digests = info.get("RepoDigests") or []
|
|
1552
|
+
for entry in digests:
|
|
1553
|
+
_, separator, digest = str(entry).partition("@")
|
|
1554
|
+
if separator:
|
|
1555
|
+
return digest
|
|
1556
|
+
return ""
|
|
1557
|
+
|
|
1558
|
+
@staticmethod
|
|
1559
|
+
def _archive_provenance(provenance: BuildProvenance, destination: str) -> None:
|
|
1560
|
+
"""Arquiva o documento ao lado do relatório. Falhar aqui não invalida
|
|
1561
|
+
o build: a procedência já está na resposta."""
|
|
1562
|
+
if not destination:
|
|
1563
|
+
return
|
|
1564
|
+
try:
|
|
1565
|
+
path = Path(destination)
|
|
1566
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1567
|
+
path.write_text(
|
|
1568
|
+
json.dumps(provenance.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8"
|
|
1569
|
+
)
|
|
1570
|
+
logger.info(f"Provenance archived at {path}")
|
|
1571
|
+
except OSError as e:
|
|
1572
|
+
logger.warning(f"Could not archive the provenance: {e}")
|
|
1573
|
+
|
|
1574
|
+
def _generate_report(
|
|
1575
|
+
self,
|
|
1576
|
+
validation: Any,
|
|
1577
|
+
build: BuildResult,
|
|
1578
|
+
scan: ScanResult | None,
|
|
1579
|
+
image_tag: str,
|
|
1580
|
+
dockerfile_path: str,
|
|
1581
|
+
remediation_history: list[dict[str, Any]] | None = None,
|
|
1582
|
+
) -> BuildReport:
|
|
1583
|
+
"""Gera relatório completo do build."""
|
|
1584
|
+
now = datetime.now(tz=UTC)
|
|
1585
|
+
build_id = self._new_build_id(image_tag)
|
|
1586
|
+
|
|
1587
|
+
# Calcular score de segurança
|
|
1588
|
+
security_score = self._calculate_security_score(validation, scan)
|
|
1589
|
+
security_tier = self._calculate_security_tier(security_score)
|
|
1590
|
+
|
|
1591
|
+
# Extrair checks de validação
|
|
1592
|
+
validation_dict = self._validation_dict(validation.validation)
|
|
1593
|
+
|
|
1594
|
+
# Resultados do scan
|
|
1595
|
+
scan_dict = None
|
|
1596
|
+
if scan:
|
|
1597
|
+
scan_dict = {
|
|
1598
|
+
"trivy" if scan.scan_tool == "trivy" else "grype": {
|
|
1599
|
+
"critical": scan.critical,
|
|
1600
|
+
"high": scan.high,
|
|
1601
|
+
"medium": scan.medium,
|
|
1602
|
+
"low": scan.low,
|
|
1603
|
+
},
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
# Metadados do build
|
|
1607
|
+
git_sha = self._get_git_sha()
|
|
1608
|
+
metadata = {
|
|
1609
|
+
"timestamp": now.isoformat(),
|
|
1610
|
+
"git_sha": git_sha,
|
|
1611
|
+
"built_by": os.environ.get("USER", "unknown"),
|
|
1612
|
+
"docker_version": self._get_docker_version(),
|
|
1613
|
+
"buildkit": True,
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
# Recomendações: vêm das sugestões de hardening. `DockerfileAnalysis`
|
|
1617
|
+
# nunca teve um atributo `recommendations` -- o acesso antigo só não
|
|
1618
|
+
# explodia porque `analysis` era sempre None nos testes.
|
|
1619
|
+
recommendations = self._recommendation_dicts(list(validation.suggestions or []))
|
|
1620
|
+
|
|
1621
|
+
history = remediation_history or []
|
|
1622
|
+
return BuildReport(
|
|
1623
|
+
build_id=build_id,
|
|
1624
|
+
timestamp=now.isoformat(),
|
|
1625
|
+
image=image_tag,
|
|
1626
|
+
dockerfile_path=dockerfile_path,
|
|
1627
|
+
validation=validation_dict,
|
|
1628
|
+
scan_results=scan_dict,
|
|
1629
|
+
security_score=security_score,
|
|
1630
|
+
security_tier=security_tier,
|
|
1631
|
+
recommendations=recommendations,
|
|
1632
|
+
build_metadata=metadata,
|
|
1633
|
+
remediation_history=history,
|
|
1634
|
+
auto_remediated=bool(history),
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
def _calculate_security_score(self, validation: Any, scan: ScanResult | None) -> int:
|
|
1638
|
+
"""Calcula score de segurança (0-100)."""
|
|
1639
|
+
score = 100
|
|
1640
|
+
|
|
1641
|
+
# Penalizar erros de validação
|
|
1642
|
+
if validation.validation:
|
|
1643
|
+
score -= validation.validation.errors * 10
|
|
1644
|
+
score -= validation.validation.warnings * 3
|
|
1645
|
+
|
|
1646
|
+
# Penalizar vulnerabilidades
|
|
1647
|
+
if scan:
|
|
1648
|
+
score -= scan.critical * 15
|
|
1649
|
+
score -= scan.high * 10
|
|
1650
|
+
score -= scan.medium * 3
|
|
1651
|
+
score -= scan.low * 1
|
|
1652
|
+
|
|
1653
|
+
return max(0, min(100, score))
|
|
1654
|
+
|
|
1655
|
+
def _calculate_security_tier(self, score: int) -> str:
|
|
1656
|
+
"""Calcula tier de segurança baseado no score."""
|
|
1657
|
+
if score >= 90:
|
|
1658
|
+
return "A"
|
|
1659
|
+
elif score >= 75:
|
|
1660
|
+
return "B"
|
|
1661
|
+
elif score >= 60:
|
|
1662
|
+
return "C"
|
|
1663
|
+
elif score >= 40:
|
|
1664
|
+
return "D"
|
|
1665
|
+
else:
|
|
1666
|
+
return "F"
|
|
1667
|
+
|
|
1668
|
+
def _get_git_sha(self) -> str | None:
|
|
1669
|
+
"""Obtém SHA do git atual.
|
|
1670
|
+
|
|
1671
|
+
Metadado opcional do relatório: fora de um repositório, ou sem git
|
|
1672
|
+
instalado, o relatório sai sem ele em vez de falhar o build.
|
|
1673
|
+
"""
|
|
1674
|
+
return self._capture_output(["git", "rev-parse", "HEAD"], "git SHA")
|
|
1675
|
+
|
|
1676
|
+
def _get_docker_version(self) -> str:
|
|
1677
|
+
"""Obtém versão do Docker."""
|
|
1678
|
+
return self._capture_output(["docker", "--version"], "the Docker version") or "unknown"
|
|
1679
|
+
|
|
1680
|
+
@staticmethod
|
|
1681
|
+
def _capture_output(argv: list[str], what: str) -> str | None:
|
|
1682
|
+
"""Roda `argv` e devolve seu stdout, ou None se não der.
|
|
1683
|
+
|
|
1684
|
+
A falha é registrada em DEBUG em vez de engolida em silêncio: um
|
|
1685
|
+
`except: pass` esconde exatamente o caso que a gente quer investigar
|
|
1686
|
+
quando o metadado sai vazio.
|
|
1687
|
+
"""
|
|
1688
|
+
try:
|
|
1689
|
+
resolved = [resolve_executable(argv[0]), *argv[1:]]
|
|
1690
|
+
result = subprocess.run( # nosec B603 # noqa: S603
|
|
1691
|
+
resolved,
|
|
1692
|
+
capture_output=True,
|
|
1693
|
+
text=True,
|
|
1694
|
+
timeout=10,
|
|
1695
|
+
check=False,
|
|
1696
|
+
)
|
|
1697
|
+
except (ExecutableNotFoundError, OSError, subprocess.SubprocessError) as e:
|
|
1698
|
+
logger.debug(f"Could not read {what}: {e}")
|
|
1699
|
+
return None
|
|
1700
|
+
|
|
1701
|
+
if result.returncode != 0:
|
|
1702
|
+
logger.debug(f"Could not read {what}: exit {result.returncode}")
|
|
1703
|
+
return None
|
|
1704
|
+
return result.stdout.strip()
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def _as_vulnerabilities(raw: list[dict[str, Any]]) -> list[Vulnerability]:
|
|
1708
|
+
"""Converte os achados crus do scanner nas entidades do domínio.
|
|
1709
|
+
|
|
1710
|
+
O `ScanResult` deste módulo carrega dicionários porque o relatório de build
|
|
1711
|
+
os serializa direto. A atribuição, porém, é lógica de domínio e trabalha
|
|
1712
|
+
com a entidade -- converter aqui é o que evita duas definições de "o que é
|
|
1713
|
+
um achado" convivendo no mesmo processo.
|
|
1714
|
+
|
|
1715
|
+
Uma severidade que o scanner reporte com um nome que não conhecemos vira
|
|
1716
|
+
`UNKNOWN` em vez de derrubar o build: a atribuição usa CVE e pacote, e a
|
|
1717
|
+
severidade é só o que se mostra ao lado.
|
|
1718
|
+
"""
|
|
1719
|
+
findings: list[Vulnerability] = []
|
|
1720
|
+
for item in raw:
|
|
1721
|
+
severity = str(item.get("severity") or "").strip().upper()
|
|
1722
|
+
findings.append(
|
|
1723
|
+
Vulnerability(
|
|
1724
|
+
cve_id=str(item.get("cve_id") or ""),
|
|
1725
|
+
package_name=str(item.get("package") or ""),
|
|
1726
|
+
severity=Severity(severity)
|
|
1727
|
+
if severity in Severity.__members__
|
|
1728
|
+
else Severity.UNKNOWN,
|
|
1729
|
+
installed_version=str(item.get("installed_version") or ""),
|
|
1730
|
+
fixed_version=str(item.get("fixed_version") or ""),
|
|
1731
|
+
)
|
|
1732
|
+
)
|
|
1733
|
+
return findings
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
async def _lookup_threat_intel(
|
|
1737
|
+
client: ThreatIntelClient, cve_ids: list[str]
|
|
1738
|
+
) -> tuple[set[str], dict[str, float]]:
|
|
1739
|
+
"""As duas consultas, numa corrida só."""
|
|
1740
|
+
kev = await client.known_exploited(cve_ids)
|
|
1741
|
+
epss = await client.epss_scores(cve_ids)
|
|
1742
|
+
return kev, epss
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def _tristate(value: object) -> Tristate:
|
|
1746
|
+
"""Lê o campo `kev` de um achado, com UNKNOWN como padrão honesto.
|
|
1747
|
+
|
|
1748
|
+
Ausente, ilegível ou de outro tipo é UNKNOWN: nada foi consultado. Só
|
|
1749
|
+
um `TRUE`/`FALSE` explícito -- escrito pelo enriquecimento, depois de o
|
|
1750
|
+
catálogo responder -- vira veredito.
|
|
1751
|
+
"""
|
|
1752
|
+
if isinstance(value, Tristate):
|
|
1753
|
+
return value
|
|
1754
|
+
if isinstance(value, str):
|
|
1755
|
+
try:
|
|
1756
|
+
# `Tristate` é minúsculo por dentro (`"true"`), e o campo pode
|
|
1757
|
+
# chegar escrito de qualquer jeito de um JSON que não é nosso.
|
|
1758
|
+
return Tristate(value.strip().lower())
|
|
1759
|
+
except ValueError:
|
|
1760
|
+
return Tristate.UNKNOWN
|
|
1761
|
+
return Tristate.UNKNOWN
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
def _as_probability(value: object) -> float | None:
|
|
1765
|
+
"""O EPSS de um achado, ou None quando ninguém consultou.
|
|
1766
|
+
|
|
1767
|
+
`0.0` é uma resposta -- o FIRST pontuou em zero -- e `None` é a falta
|
|
1768
|
+
dela. Colapsar as duas faria o portão comparar uma ausência com um
|
|
1769
|
+
piso e concluir "abaixo do limiar", que é ausência de medição gasta
|
|
1770
|
+
como tranquilidade.
|
|
1771
|
+
"""
|
|
1772
|
+
if isinstance(value, bool) or value is None:
|
|
1773
|
+
return None
|
|
1774
|
+
if isinstance(value, int | float):
|
|
1775
|
+
return float(value)
|
|
1776
|
+
return None
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def _origin_hint(inheritance: InheritanceReport | None) -> str:
|
|
1780
|
+
"""Uma frase curta sobre de onde vieram os achados, quando se sabe.
|
|
1781
|
+
|
|
1782
|
+
Fica vazia quando ninguém pediu `--attribute` ou quando a atribuição não
|
|
1783
|
+
fechou: um portão que insinua uma origem que não mediu é pior do que um
|
|
1784
|
+
portão calado.
|
|
1785
|
+
"""
|
|
1786
|
+
if inheritance is None or not inheritance.available:
|
|
1787
|
+
return ""
|
|
1788
|
+
herdadas, suas = len(inheritance.inherited), len(inheritance.introduced)
|
|
1789
|
+
if not herdadas and not suas:
|
|
1790
|
+
return ""
|
|
1791
|
+
corrigiveis = inheritance.fixable_inherited
|
|
1792
|
+
return (
|
|
1793
|
+
f" [{herdadas} from the base {inheritance.base_reference}"
|
|
1794
|
+
f" ({corrigiveis} with a published fix), {suas} from your layers]"
|
|
1795
|
+
)
|