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,1300 @@
|
|
|
1
|
+
"""Comando CLI para build seguro de imagens Docker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from html import escape as html_escape
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
|
|
15
|
+
from dockerls.application.use_cases.build_image import (
|
|
16
|
+
BuildImageRequest,
|
|
17
|
+
BuildImageResponse,
|
|
18
|
+
BuildImageUseCase,
|
|
19
|
+
BuildReport,
|
|
20
|
+
)
|
|
21
|
+
from dockerls.cli.dependencies import enable_console_logging
|
|
22
|
+
from dockerls.cli.publish_prompt import resolve_destination, resolve_identity
|
|
23
|
+
from dockerls.cli.rendering import render_validation_report
|
|
24
|
+
from dockerls.cli.text import safe
|
|
25
|
+
from dockerls.domain.value_objects.build_labels import BuildIdentity, MissingBuildMetadataError
|
|
26
|
+
from dockerls.domain.value_objects.build_policy import BuildPolicy
|
|
27
|
+
from dockerls.domain.value_objects.gate import (
|
|
28
|
+
GateKind,
|
|
29
|
+
GateSet,
|
|
30
|
+
InvalidGateError,
|
|
31
|
+
merge_gates,
|
|
32
|
+
)
|
|
33
|
+
from dockerls.domain.value_objects.inheritance import ACTIONS, FindingOrigin
|
|
34
|
+
from dockerls.domain.value_objects.provenance import BuildProvenance, ProvenanceStatus
|
|
35
|
+
from dockerls.domain.value_objects.registry_target import InvalidRegistryTargetError
|
|
36
|
+
from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
|
|
37
|
+
from dockerls.infrastructure.config.policy_file import (
|
|
38
|
+
PolicyFileError,
|
|
39
|
+
find_policy_file,
|
|
40
|
+
load_policy,
|
|
41
|
+
)
|
|
42
|
+
from dockerls.infrastructure.dockerfile_validator import DockerfileValidator, HardeningTemplates
|
|
43
|
+
from dockerls.integrations.signing.cosign import (
|
|
44
|
+
CosignClient,
|
|
45
|
+
SignatureResult,
|
|
46
|
+
SignatureStatus,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from dockerls.domain.entities.dockerfile_analysis import DockerfileValidationResult
|
|
51
|
+
from dockerls.domain.value_objects.build_policy import PolicyViolation
|
|
52
|
+
from dockerls.domain.value_objects.inheritance import InheritanceReport
|
|
53
|
+
from dockerls.integrations.threat_intel.client import ThreatIntelClient
|
|
54
|
+
|
|
55
|
+
console = Console()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build(
|
|
59
|
+
path: str = typer.Argument(".", help="Directory containing the Dockerfile"),
|
|
60
|
+
tag: str | None = typer.Option(None, "--tag", "-t", help="Image tag (required)"),
|
|
61
|
+
base: str | None = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--base",
|
|
64
|
+
help=(
|
|
65
|
+
"Hardened base template: alpine, debian, ubuntu, distroless, "
|
|
66
|
+
"node-alpine, python-alpine, maven-alpine, go-scratch, ... "
|
|
67
|
+
"(--list-templates shows all 39)"
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
hardened: bool = typer.Option(False, "--hardened", help="Use hardened Dockerfile templates"),
|
|
71
|
+
list_templates: bool = typer.Option(
|
|
72
|
+
False, "--list-templates", help="List the available hardened templates and exit"
|
|
73
|
+
),
|
|
74
|
+
interactive: bool = typer.Option(
|
|
75
|
+
False, "--interactive", "-i", help="Step-by-step security wizard"
|
|
76
|
+
),
|
|
77
|
+
scan: bool = typer.Option(True, "--scan/--no-scan", help="Run Trivy/Grype after the build"),
|
|
78
|
+
auto_remediate: bool = typer.Option(
|
|
79
|
+
False,
|
|
80
|
+
"--auto-fix",
|
|
81
|
+
"--auto-remediate",
|
|
82
|
+
help="Loop auto-remediation until zero vulnerabilities",
|
|
83
|
+
),
|
|
84
|
+
zero_vulns: bool = typer.Option(
|
|
85
|
+
False, "--zero-vulns", help="Build and remediate until zero CVEs"
|
|
86
|
+
),
|
|
87
|
+
max_iterations: int = typer.Option(
|
|
88
|
+
3, "--max-iterations", help="Maximum number of remediation iterations"
|
|
89
|
+
),
|
|
90
|
+
fail_on: str | None = typer.Option(
|
|
91
|
+
None,
|
|
92
|
+
"--fail-on",
|
|
93
|
+
help="Fail the build on: a severity (critical, high, medium, low), `kev` "
|
|
94
|
+
"(exploited in the wild, per CISA KEV), or `epss>=N` (probability of "
|
|
95
|
+
"exploitation). Combine with commas; all of them must pass",
|
|
96
|
+
),
|
|
97
|
+
report: str | None = typer.Option(
|
|
98
|
+
None, "--report", "-r", help="Save the security report (JSON/HTML)"
|
|
99
|
+
),
|
|
100
|
+
no_cache: bool = typer.Option(False, "--no-cache", help="Disable the Docker build cache"),
|
|
101
|
+
build_args: str | None = typer.Option(None, "--build-args", help="Build arguments (JSON)"),
|
|
102
|
+
labels: str | None = typer.Option(None, "--labels", help="Security labels (JSON)"),
|
|
103
|
+
ci_mode: bool = typer.Option(
|
|
104
|
+
False, "--ci-mode", help="CI/CD mode (JSON output, no interaction)"
|
|
105
|
+
),
|
|
106
|
+
validate_only: bool = typer.Option(
|
|
107
|
+
False, "--validate-only", help="Only validate the Dockerfile"
|
|
108
|
+
),
|
|
109
|
+
suggest_hardening: bool = typer.Option(
|
|
110
|
+
False, "--suggest-hardening", help="Suggest improvements without building"
|
|
111
|
+
),
|
|
112
|
+
push: bool = typer.Option(False, "--push", help="docker push the tag after a successful build"),
|
|
113
|
+
registry: str | None = typer.Option(
|
|
114
|
+
None,
|
|
115
|
+
"--registry",
|
|
116
|
+
"--acr",
|
|
117
|
+
help=(
|
|
118
|
+
"Publish destination, without a tag: myacr.azurecr.io/apps/app, "
|
|
119
|
+
"us-central1-docker.pkg.dev/proj/repo/app, gcr.io/proj/app, myorg/app"
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
owner: str | None = typer.Option(
|
|
123
|
+
None, "--owner", help="Owning team or person (becomes maintainer and vendor)"
|
|
124
|
+
),
|
|
125
|
+
security_contact: str | None = typer.Option(
|
|
126
|
+
None, "--security-contact", help="Contact for vulnerabilities in this image"
|
|
127
|
+
),
|
|
128
|
+
source_url: str | None = typer.Option(
|
|
129
|
+
None, "--source", help="URL of the repository that produces this image"
|
|
130
|
+
),
|
|
131
|
+
provenance: str | None = typer.Option(
|
|
132
|
+
None,
|
|
133
|
+
"--provenance",
|
|
134
|
+
help="Archive the supply-chain record (input and output hashes) as JSON",
|
|
135
|
+
),
|
|
136
|
+
production: bool = typer.Option(
|
|
137
|
+
False,
|
|
138
|
+
"--production",
|
|
139
|
+
help=(
|
|
140
|
+
"Production profile: gate at critical, require a scan, pinned bases, an "
|
|
141
|
+
"unprivileged user, verified provenance, ownership labels and finding "
|
|
142
|
+
"attribution. Says in the output what it turned on"
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
attribute: bool = typer.Option(
|
|
146
|
+
False,
|
|
147
|
+
"--attribute",
|
|
148
|
+
help=(
|
|
149
|
+
"Also scan the declared base and say whose each CVE is: the base, or the "
|
|
150
|
+
"layers of this Dockerfile. Costs a second scan"
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
sign: bool = typer.Option(
|
|
154
|
+
False,
|
|
155
|
+
"--sign",
|
|
156
|
+
help=(
|
|
157
|
+
"Sign the published image with cosign (keyless/OIDC). Requires --push and "
|
|
158
|
+
"verified provenance"
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
policy: str | None = typer.Option(
|
|
162
|
+
None,
|
|
163
|
+
"--policy",
|
|
164
|
+
help=("Policy file to check (default: .dockerls-policy.yaml in the context, when present)"),
|
|
165
|
+
),
|
|
166
|
+
no_policy: bool = typer.Option(
|
|
167
|
+
False,
|
|
168
|
+
"--no-policy",
|
|
169
|
+
help="Ignore the context .dockerls-policy.yaml. Recorded in the output",
|
|
170
|
+
),
|
|
171
|
+
non_interactive: bool = typer.Option(
|
|
172
|
+
False,
|
|
173
|
+
"--non-interactive",
|
|
174
|
+
help="Ask nothing: anything missing becomes an error instead of stalling the pipeline",
|
|
175
|
+
),
|
|
176
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose debug output"),
|
|
177
|
+
output: str | None = typer.Option(None, "--output", "-o", help="Report output file"),
|
|
178
|
+
force: bool = typer.Option(False, "--force", help="Build even when validation fails"),
|
|
179
|
+
compare_to_analysis: str | None = typer.Option(
|
|
180
|
+
None,
|
|
181
|
+
"--compare-to-analysis",
|
|
182
|
+
help="A report written by an earlier `analyze-dockerfile --output <file>` run "
|
|
183
|
+
"on this same Dockerfile. When given, the build report says which of those "
|
|
184
|
+
"findings are still present now and which were fixed",
|
|
185
|
+
),
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Build secure Docker images with validation, scanning and auto-remediation."""
|
|
188
|
+
if verbose:
|
|
189
|
+
enable_console_logging()
|
|
190
|
+
|
|
191
|
+
template_provider = HardeningTemplates()
|
|
192
|
+
|
|
193
|
+
if list_templates:
|
|
194
|
+
_print_templates(template_provider, ci_mode=ci_mode)
|
|
195
|
+
raise typer.Exit(EXIT_OK)
|
|
196
|
+
|
|
197
|
+
# Validar tag obrigatória (exceto em modos especiais)
|
|
198
|
+
if not tag and not validate_only and not suggest_hardening and not interactive:
|
|
199
|
+
console.print("[red]Error:[/red] --tag is required for build")
|
|
200
|
+
raise typer.Exit(EXIT_ERROR)
|
|
201
|
+
|
|
202
|
+
# Um limiar desconhecido não pode virar um portão que nunca reprova:
|
|
203
|
+
# rejeita antes de construir qualquer coisa.
|
|
204
|
+
if fail_on is not None:
|
|
205
|
+
try:
|
|
206
|
+
GateSet.parse(fail_on)
|
|
207
|
+
except InvalidGateError as e:
|
|
208
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
209
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
210
|
+
|
|
211
|
+
# Uma base sem template não pode ser descoberta só depois do build ter
|
|
212
|
+
# começado -- o mesmo raciocínio do `--fail-on` acima.
|
|
213
|
+
if base is not None:
|
|
214
|
+
known = template_provider.list_templates()
|
|
215
|
+
# Nome exato. A comparação anterior perguntava se algum template era
|
|
216
|
+
# *substring* do que foi digitado, então `--base alpine-inexistente`
|
|
217
|
+
# passava aqui (por conter "alpine") e explodia lá dentro, na geração.
|
|
218
|
+
if base.strip().lower() not in known:
|
|
219
|
+
console.print(
|
|
220
|
+
f"[red]Error:[/red] invalid --base: {base!r}.\n"
|
|
221
|
+
f"[dim]Available: {', '.join(known)}[/dim]"
|
|
222
|
+
)
|
|
223
|
+
raise typer.Exit(EXIT_ERROR)
|
|
224
|
+
|
|
225
|
+
declared_policy = _load_policy(path, policy, no_policy=no_policy)
|
|
226
|
+
if production:
|
|
227
|
+
declared_policy = _announce_production(declared_policy)
|
|
228
|
+
attribute = True
|
|
229
|
+
|
|
230
|
+
# Parsear JSON args
|
|
231
|
+
build_args_dict = _parse_json_option(build_args, "--build-args")
|
|
232
|
+
labels_dict = _parse_json_option(labels, "--labels")
|
|
233
|
+
|
|
234
|
+
# Publicar sem veredito é a contradição que esta ferramenta existe para
|
|
235
|
+
# não cometer: o portão passa a ser obrigatório para quem publica, em vez
|
|
236
|
+
# de depender de alguém lembrar de passar --fail-on. `--no-scan` com push
|
|
237
|
+
# é recusado de saída -- sem medição não há veredito nenhum a dar.
|
|
238
|
+
publishing = push or bool(registry)
|
|
239
|
+
if publishing and not scan:
|
|
240
|
+
console.print(
|
|
241
|
+
"[red]Error:[/red] --push with --no-scan would publish an image nobody "
|
|
242
|
+
"measured. An unmeasured image is not a secure image; it is an unknown "
|
|
243
|
+
"one."
|
|
244
|
+
)
|
|
245
|
+
raise typer.Exit(EXIT_ERROR)
|
|
246
|
+
if publishing and fail_on is None:
|
|
247
|
+
fail_on = "critical"
|
|
248
|
+
console.print(
|
|
249
|
+
"[dim]Publishing: security gate at `critical` by default "
|
|
250
|
+
"(use --fail-on to change the threshold).[/dim]"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Destino e responsabilidade são resolvidos **antes** do build: descobrir
|
|
254
|
+
# que o destino está errado depois de validar, construir e escanear
|
|
255
|
+
# desperdiça o trabalho inteiro, e rotular depois do build significa
|
|
256
|
+
# reconstruir.
|
|
257
|
+
quiet = non_interactive or ci_mode
|
|
258
|
+
identity = BuildIdentity(
|
|
259
|
+
owner=(owner or "").strip(),
|
|
260
|
+
security_contact=(security_contact or "").strip(),
|
|
261
|
+
source=(source_url or "").strip(),
|
|
262
|
+
version=(tag or "").rpartition(":")[2],
|
|
263
|
+
extra=labels_dict or {},
|
|
264
|
+
)
|
|
265
|
+
target = None
|
|
266
|
+
try:
|
|
267
|
+
if push or registry:
|
|
268
|
+
target = resolve_destination(registry, _tag_part(tag), non_interactive=quiet)
|
|
269
|
+
# Os rótulos só são exigidos de quem vai publicar: um build local para
|
|
270
|
+
# experimentar não precisa de dono, e transformar isso em obstáculo
|
|
271
|
+
# faria as pessoas desligarem a checagem inteira.
|
|
272
|
+
if target is not None:
|
|
273
|
+
identity = resolve_identity(identity, non_interactive=quiet)
|
|
274
|
+
except (InvalidRegistryTargetError, MissingBuildMetadataError) as e:
|
|
275
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
276
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
277
|
+
|
|
278
|
+
labels_dict = {**identity.to_labels(), **(labels_dict or {})}
|
|
279
|
+
|
|
280
|
+
# Inicializar use case
|
|
281
|
+
validator = DockerfileValidator(template_provider)
|
|
282
|
+
# A inteligência de ameaça só é montada quando algum portão a pede:
|
|
283
|
+
# `--fail-on high` não deve sair para a rede consultar KEV, e um build
|
|
284
|
+
# sem portão de exploração não deve consultar nada.
|
|
285
|
+
use_case = BuildImageUseCase(
|
|
286
|
+
validator, template_provider, threat_intel=_threat_intel_for(fail_on, declared_policy)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Criar request
|
|
290
|
+
request = BuildImageRequest(
|
|
291
|
+
context_path=path,
|
|
292
|
+
tag=tag or "temp:latest",
|
|
293
|
+
dockerfile_path="Dockerfile",
|
|
294
|
+
hardened=hardened,
|
|
295
|
+
base_template=base,
|
|
296
|
+
scan=scan,
|
|
297
|
+
validate_only=validate_only,
|
|
298
|
+
suggest_only=suggest_hardening,
|
|
299
|
+
no_cache=no_cache,
|
|
300
|
+
build_args=build_args_dict,
|
|
301
|
+
labels=labels_dict,
|
|
302
|
+
fail_on=fail_on,
|
|
303
|
+
ci_mode=ci_mode,
|
|
304
|
+
verbose=verbose,
|
|
305
|
+
force=force,
|
|
306
|
+
push=push or target is not None,
|
|
307
|
+
push_reference=target.reference if target else "",
|
|
308
|
+
provenance_path=(provenance or "").strip(),
|
|
309
|
+
auto_remediate=auto_remediate or zero_vulns,
|
|
310
|
+
max_remediation_rounds=max_iterations,
|
|
311
|
+
target_zero_vulns=zero_vulns,
|
|
312
|
+
policy=declared_policy,
|
|
313
|
+
attribute_findings=attribute,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Executar
|
|
317
|
+
response = _run_interactive_wizard(use_case, path) if interactive else use_case.execute(request)
|
|
318
|
+
|
|
319
|
+
signature = _sign_if_requested(response, sign=sign, publishing=publishing)
|
|
320
|
+
|
|
321
|
+
if compare_to_analysis and response.validation is not None:
|
|
322
|
+
_print_analysis_comparison(compare_to_analysis, response.validation)
|
|
323
|
+
|
|
324
|
+
# Output
|
|
325
|
+
if ci_mode or output:
|
|
326
|
+
_print_json_output(response, output, signature=signature)
|
|
327
|
+
else:
|
|
328
|
+
_print_table_output(response, report)
|
|
329
|
+
if signature is not None:
|
|
330
|
+
_print_signature(signature)
|
|
331
|
+
|
|
332
|
+
# Assinar e falhar deixaria o pipeline verde com uma imagem publicada que
|
|
333
|
+
# ninguém atestou -- e o próximo `dockerls verify` seria a primeira notícia
|
|
334
|
+
# disso, tarde demais.
|
|
335
|
+
if signature is not None and not signature.trustworthy:
|
|
336
|
+
raise typer.Exit(EXIT_ERROR)
|
|
337
|
+
raise typer.Exit(response.exit_code)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _sign_if_requested(
|
|
341
|
+
response: BuildImageResponse, *, sign: bool, publishing: bool
|
|
342
|
+
) -> SignatureResult | None:
|
|
343
|
+
"""Assina a imagem publicada, quando pedido e quando é legítimo assinar.
|
|
344
|
+
|
|
345
|
+
Duas recusas moram aqui, e as duas são sobre o mesmo erro: uma assinatura
|
|
346
|
+
aponta para bytes específicos e diz "eu publiquei isto". Emiti-la sobre um
|
|
347
|
+
artefato que não se sabe de onde veio transforma a assinatura em carimbo.
|
|
348
|
+
"""
|
|
349
|
+
if not sign:
|
|
350
|
+
return None
|
|
351
|
+
if not publishing or not response.success:
|
|
352
|
+
console.print(
|
|
353
|
+
"[yellow]--sign ignored: only what was published can be signed, and this "
|
|
354
|
+
"build never published.[/yellow]"
|
|
355
|
+
)
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
record = response.provenance
|
|
359
|
+
if record is None or not record.is_verified:
|
|
360
|
+
reason = record.explain() if record else "no provenance was recorded"
|
|
361
|
+
console.print(
|
|
362
|
+
f"[red]Signing refused:[/red] {safe(reason)}.\n"
|
|
363
|
+
"[dim]Signing asserts that you published these bytes; doing it over an "
|
|
364
|
+
"artifact whose inputs do not add up would be stamping the unknown.[/dim]"
|
|
365
|
+
)
|
|
366
|
+
return SignatureResult(
|
|
367
|
+
reference=response.image_tag or "",
|
|
368
|
+
status=SignatureStatus.FAILED,
|
|
369
|
+
detail="provenance not verified",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
digest = record.artifact.repo_digest
|
|
373
|
+
reference = record.artifact.published_reference or response.image_tag or ""
|
|
374
|
+
if not digest:
|
|
375
|
+
console.print(
|
|
376
|
+
"[red]Signing refused:[/red] the registry did not return the manifest "
|
|
377
|
+
"digest.\n[dim]Signing the tag would sign what it points at now, and it "
|
|
378
|
+
"can move an instant later -- the signature would stay valid over "
|
|
379
|
+
"different bytes.[/dim]"
|
|
380
|
+
)
|
|
381
|
+
return SignatureResult(
|
|
382
|
+
reference=reference,
|
|
383
|
+
status=SignatureStatus.FAILED,
|
|
384
|
+
detail="no manifest digest",
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
alvo = _digest_reference(reference, digest)
|
|
388
|
+
console.print(f"[dim]Signing {safe(alvo)} with cosign (keyless).[/dim]")
|
|
389
|
+
return asyncio.run(CosignClient().sign(alvo))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _announce_production(declared: BuildPolicy | None) -> BuildPolicy:
|
|
393
|
+
"""Liga o perfil de produção e **diz o que ligou**.
|
|
394
|
+
|
|
395
|
+
Um perfil que muda o comportamento em silêncio é um perfil que a pessoa
|
|
396
|
+
descobre pelo build reprovando, e a primeira reação a um portão que
|
|
397
|
+
reprova sem explicar é desligá-lo.
|
|
398
|
+
|
|
399
|
+
Um `.dockerls-policy.yaml` no contexto continua valendo, e só pode
|
|
400
|
+
apertar: `--production` é um piso, não um teto.
|
|
401
|
+
"""
|
|
402
|
+
perfil = BuildPolicy.production().merged_with(declared)
|
|
403
|
+
console.print("\n[bold]Production profile[/bold]")
|
|
404
|
+
for regra, valor in perfil.to_dict().items():
|
|
405
|
+
if valor:
|
|
406
|
+
console.print(f" [cyan]{regra}[/cyan] [dim]{safe(_describe_rule(valor))}[/dim]")
|
|
407
|
+
if declared is not None:
|
|
408
|
+
console.print(
|
|
409
|
+
" [dim]combined with the context .dockerls-policy.yaml, always by the "
|
|
410
|
+
"stricter side[/dim]"
|
|
411
|
+
)
|
|
412
|
+
console.print()
|
|
413
|
+
return perfil
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _describe_rule(value: object) -> str:
|
|
417
|
+
if isinstance(value, dict):
|
|
418
|
+
return ", ".join(f"{k}: {v}" for k, v in value.items())
|
|
419
|
+
if isinstance(value, list):
|
|
420
|
+
return ", ".join(str(v) for v in value)
|
|
421
|
+
return str(value)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _print_inheritance(report: InheritanceReport | None) -> None:
|
|
425
|
+
"""De quem é cada CVE -- a resposta para "consertar o quê?".
|
|
426
|
+
|
|
427
|
+
Uma contagem sozinha manda consertar sem dizer o quê, e quem lê passa a
|
|
428
|
+
tarde descobrindo que nada no Dockerfile dela resolve o problema.
|
|
429
|
+
"""
|
|
430
|
+
if report is None:
|
|
431
|
+
return
|
|
432
|
+
if not report.available:
|
|
433
|
+
console.print(
|
|
434
|
+
f"\n[yellow]Attribution unavailable:[/yellow] [dim]{safe(report.explain())}[/dim]"
|
|
435
|
+
)
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
console.print("\n[bold]Where the vulnerabilities come from[/bold]")
|
|
439
|
+
console.print(f"[dim]{safe(report.explain())}[/dim]\n")
|
|
440
|
+
|
|
441
|
+
linhas = (
|
|
442
|
+
("inherited from base", len(report.inherited), FindingOrigin.INHERITED, "yellow"),
|
|
443
|
+
("from your layers", len(report.introduced), FindingOrigin.INTRODUCED, "red"),
|
|
444
|
+
("removed in build", len(report.removed), FindingOrigin.REMOVED, "green"),
|
|
445
|
+
)
|
|
446
|
+
for rotulo, quantidade, origem, cor in linhas:
|
|
447
|
+
if not quantidade:
|
|
448
|
+
continue
|
|
449
|
+
console.print(f" [{cor}]{quantidade:>4}[/{cor}] {rotulo}")
|
|
450
|
+
console.print(f" [dim]{safe(ACTIONS[origem])}[/dim]")
|
|
451
|
+
|
|
452
|
+
_print_plan(report)
|
|
453
|
+
|
|
454
|
+
if report.inherited_share >= 0.5 and report.inherited:
|
|
455
|
+
console.print(
|
|
456
|
+
f"\n[yellow]{report.inherited_share:.0%} of this image vulnerabilities "
|
|
457
|
+
"came from the base.[/yellow]\n[dim]Changing your Dockerfile does not "
|
|
458
|
+
"address that part: run `dockerls base --alternatives` to measure another "
|
|
459
|
+
"base.[/dim]"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _print_analysis_comparison(baseline_path: str, validation: DockerfileValidationResult) -> None:
|
|
464
|
+
"""How this build's own validation compares to an earlier
|
|
465
|
+
`analyze-dockerfile --output` report on the same Dockerfile.
|
|
466
|
+
|
|
467
|
+
Both come from the exact same rule set -- `build` always runs its own
|
|
468
|
+
fresh validation as step one -- so this is a same-Dockerfile,
|
|
469
|
+
then-and-now comparison, not a guess about what changed.
|
|
470
|
+
"""
|
|
471
|
+
from dockerls.cli.analysis_baseline import BaselineLoadError, compare, load_baseline_findings
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
baseline = load_baseline_findings(baseline_path)
|
|
475
|
+
except BaselineLoadError as e:
|
|
476
|
+
console.print(f"\n[yellow]--compare-to-analysis:[/yellow] {safe(str(e))}")
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
result = compare(baseline, validation)
|
|
480
|
+
if not result.baseline_total and not result.newly_introduced:
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
console.print("\n[bold]Compared to the earlier analyze-dockerfile run[/bold]")
|
|
484
|
+
if result.baseline_total:
|
|
485
|
+
console.print(
|
|
486
|
+
f"[dim]{len(result.still_present)}/{result.baseline_total} of its "
|
|
487
|
+
"warnings/errors are still present in this build.[/dim]"
|
|
488
|
+
)
|
|
489
|
+
for name in result.resolved:
|
|
490
|
+
console.print(f" [green]fixed[/green] {safe(name)}")
|
|
491
|
+
for name in result.still_present:
|
|
492
|
+
console.print(f" [yellow]still open[/yellow] {safe(name)}")
|
|
493
|
+
for name in result.newly_introduced:
|
|
494
|
+
console.print(f" [red]new[/red] {safe(name)}")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _print_plan(report: InheritanceReport) -> None:
|
|
498
|
+
"""O plano de trabalho: origem cruzada com "existe correção?".
|
|
499
|
+
|
|
500
|
+
Origem sozinha diz de quem é o problema; correção diz se ele tem solução.
|
|
501
|
+
Só as duas juntas dizem o que fazer na segunda-feira -- e a diferença é
|
|
502
|
+
grande: se nenhuma das herdadas tem correção publicada, atualizar a base é
|
|
503
|
+
trabalho perdido.
|
|
504
|
+
"""
|
|
505
|
+
plano = report.plan()
|
|
506
|
+
if not plano:
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
console.print("\n[bold]Work plan[/bold]")
|
|
510
|
+
for bucket in plano:
|
|
511
|
+
de_onde = "from the base" if bucket.origin is FindingOrigin.INHERITED else "yours"
|
|
512
|
+
com_correcao = "fixable" if bucket.fixable else "no fix"
|
|
513
|
+
criticas = f", {bucket.critical} CRITICAL" if bucket.critical else ""
|
|
514
|
+
cor = "red" if bucket.critical else "yellow"
|
|
515
|
+
console.print(f" [{cor}]{bucket.count:>4}[/{cor}] {de_onde}, {com_correcao}{criticas}")
|
|
516
|
+
console.print(f" [dim]{safe(bucket.action())}[/dim]")
|
|
517
|
+
# Os três primeiros por severidade: uma lista completa aqui viraria
|
|
518
|
+
# rolagem, e quem quer todos usa --format json.
|
|
519
|
+
amostra = ", ".join(f"{f.cve_id} ({f.package_name})" for f in bucket.findings[:3])
|
|
520
|
+
if amostra:
|
|
521
|
+
resto = f" and {bucket.count - 3} more" if bucket.count > 3 else ""
|
|
522
|
+
console.print(f" [dim]{safe(amostra)}{resto}[/dim]")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _digest_reference(reference: str, digest: str) -> str:
|
|
526
|
+
"""`reg.io/app:1.0` + digest -> `reg.io/app@sha256:...`.
|
|
527
|
+
|
|
528
|
+
A tag sai fora. `nome:tag@digest` é válido e o digest é quem manda, mas
|
|
529
|
+
manter os dois convida quem lê a achar que a tag importa -- e a assinatura
|
|
530
|
+
existe justamente porque ela não importa.
|
|
531
|
+
"""
|
|
532
|
+
head = reference.split("@", 1)[0]
|
|
533
|
+
repositorio, separador, cauda = head.rpartition(":")
|
|
534
|
+
# `registry:5000/app` tem `:` no host, não na tag.
|
|
535
|
+
if separador and "/" not in cauda:
|
|
536
|
+
head = repositorio
|
|
537
|
+
return f"{head}@{digest}"
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _print_signature(signature: SignatureResult) -> None:
|
|
541
|
+
cor = "green" if signature.trustworthy or signature.status is SignatureStatus.SIGNED else "red"
|
|
542
|
+
console.print(f"\n[{cor}]{signature.status}[/{cor}] [dim]{safe(signature.explain())}[/dim]")
|
|
543
|
+
if signature.detail and not signature.trustworthy:
|
|
544
|
+
console.print(f"[dim]{safe(signature.detail)}[/dim]")
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _threat_intel_for(fail_on: str | None, policy: BuildPolicy | None) -> ThreatIntelClient | None:
|
|
548
|
+
"""O cliente de inteligência de ameaça, quando algum portão o exige.
|
|
549
|
+
|
|
550
|
+
Montá-lo sempre faria todo `dockerls build` sair para a rede buscar o
|
|
551
|
+
catálogo KEV e as pontuações EPSS -- para um portão que ninguém pediu.
|
|
552
|
+
Montá-lo nunca faria `--fail-on kev` não ter o que consultar, e um
|
|
553
|
+
portão de segurança que não avalia é a pior falha possível: a que não
|
|
554
|
+
aparece.
|
|
555
|
+
|
|
556
|
+
A pergunta é feita sobre o portão **efetivo**, política somada à linha
|
|
557
|
+
de comando: um `.dockerls-policy.yaml` que exige `kev` tem de fazer o
|
|
558
|
+
cliente existir mesmo que a linha de comando não peça nada.
|
|
559
|
+
"""
|
|
560
|
+
declared = policy.fail_on if policy is not None else ""
|
|
561
|
+
effective = merge_gates(declared, fail_on or "")
|
|
562
|
+
if not effective:
|
|
563
|
+
return None
|
|
564
|
+
try:
|
|
565
|
+
gates = GateSet.parse(effective)
|
|
566
|
+
except InvalidGateError:
|
|
567
|
+
# Valor inválido já foi recusado antes de chegar aqui; se escapou,
|
|
568
|
+
# não é este o lugar de reclamar.
|
|
569
|
+
return None
|
|
570
|
+
if not any(gate.kind in (GateKind.KEV, GateKind.EPSS) for gate in gates.gates):
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
from dockerls.integrations.threat_intel.client import ThreatIntelClient
|
|
574
|
+
|
|
575
|
+
return ThreatIntelClient()
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _load_policy(context: str, explicit: str | None, *, no_policy: bool) -> BuildPolicy | None:
|
|
579
|
+
"""A política a conferir neste build, ou `None` quando não há nenhuma.
|
|
580
|
+
|
|
581
|
+
Um arquivo de política ilegível **encerra o comando**, em vez de virar
|
|
582
|
+
"sem política". A direção da falha é o que decide: uma regra que não
|
|
583
|
+
carrega deixa de exigir alguma coisa, e o build passaria parecendo ter
|
|
584
|
+
sido conferido. Uma chave digitada errado seria um portão aberto com cara
|
|
585
|
+
de fechado, e ninguém descobre isso olhando a saída verde.
|
|
586
|
+
"""
|
|
587
|
+
if no_policy:
|
|
588
|
+
console.print(
|
|
589
|
+
"[yellow]--no-policy: the context .dockerls-policy.yaml will not be "
|
|
590
|
+
"conferido neste build.[/yellow]"
|
|
591
|
+
)
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
target = Path(explicit) if explicit else find_policy_file(Path(context))
|
|
595
|
+
if target is None:
|
|
596
|
+
return None
|
|
597
|
+
if explicit and not target.is_file():
|
|
598
|
+
console.print(f"[red]Error:[/red] policy file not found: {safe(explicit)}")
|
|
599
|
+
raise typer.Exit(EXIT_ERROR)
|
|
600
|
+
|
|
601
|
+
try:
|
|
602
|
+
declared = load_policy(target)
|
|
603
|
+
except PolicyFileError as e:
|
|
604
|
+
console.print(f"[red]Error:[/red] {safe(str(e))}")
|
|
605
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
606
|
+
|
|
607
|
+
console.print(f"[dim]Policy declared in {safe(str(target))} will be checked.[/dim]")
|
|
608
|
+
return declared
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _print_policy_violations(violations: list[PolicyViolation]) -> None:
|
|
612
|
+
if not violations:
|
|
613
|
+
return
|
|
614
|
+
console.print("\n[bold red]Policy not met[/bold red]")
|
|
615
|
+
for violation in violations:
|
|
616
|
+
console.print(f" [red]x[/red] [bold]{violation.rule}[/bold] {safe(violation.message)}")
|
|
617
|
+
console.print(
|
|
618
|
+
"\n[dim]These rules come from the `--production` profile and/or the context "
|
|
619
|
+
".dockerls-policy.yaml. That file is versioned alongside the code: changing it "
|
|
620
|
+
"is a reviewable change, passing a different flag on the command line is "
|
|
621
|
+
"not.[/dim]"
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _parse_json_option(raw: str | None, flag: str) -> dict[str, str] | None:
|
|
626
|
+
"""Parseia um argumento JSON de linha de comando, ou aborta com exit 1."""
|
|
627
|
+
if not raw:
|
|
628
|
+
return None
|
|
629
|
+
try:
|
|
630
|
+
parsed: dict[str, str] = json.loads(raw)
|
|
631
|
+
except json.JSONDecodeError as e:
|
|
632
|
+
console.print(f"[red]Error parsing {flag}:[/red] {e}")
|
|
633
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
634
|
+
return parsed
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _print_templates(template_provider: HardeningTemplates, ci_mode: bool = False) -> None:
|
|
638
|
+
"""Lista os templates hardened que `--base`/`--hardened` aceitam."""
|
|
639
|
+
templates = template_provider.list_templates()
|
|
640
|
+
if ci_mode:
|
|
641
|
+
typer.echo(json.dumps({"templates": templates}, indent=2))
|
|
642
|
+
return
|
|
643
|
+
|
|
644
|
+
console.print(Panel("[bold cyan]Available hardened templates[/bold cyan]", expand=False))
|
|
645
|
+
|
|
646
|
+
# Agrupado por stack, com o sistema operacional visível. Uma lista plana de
|
|
647
|
+
# quase quarenta nomes não responde a pergunta que a pessoa tem, que é
|
|
648
|
+
# "qual serve para a MINHA aplicação, e sobre qual SO ela vai rodar".
|
|
649
|
+
grouped: dict[str, list[str]] = {}
|
|
650
|
+
for name in templates:
|
|
651
|
+
stack = name.split("-", 1)[0] if "-" in name else name
|
|
652
|
+
if name in _STANDALONE_OS:
|
|
653
|
+
stack = "so"
|
|
654
|
+
grouped.setdefault(stack, []).append(name)
|
|
655
|
+
|
|
656
|
+
for stack in sorted(grouped, key=lambda s: (s != "so", s)):
|
|
657
|
+
title = _STACK_TITLES.get(stack, stack.capitalize())
|
|
658
|
+
console.print(f"\n[bold]{title}[/bold]")
|
|
659
|
+
for name in grouped[stack]:
|
|
660
|
+
console.print(f" [cyan]{name:<18}[/cyan] [dim]{_TEMPLATE_HINTS.get(name, '')}[/dim]")
|
|
661
|
+
|
|
662
|
+
console.print("\n[bold]Examples[/bold]")
|
|
663
|
+
for example in _BUILD_EXAMPLES:
|
|
664
|
+
console.print(f" [dim]{example}[/dim]")
|
|
665
|
+
console.print(
|
|
666
|
+
"\n[dim]Without --base or --hardened, the build uses the Dockerfile already "
|
|
667
|
+
"in the directory -- templates only apply when you ask for one.[/dim]"
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
#: Templates que são só o sistema operacional, sem runtime de linguagem.
|
|
672
|
+
_STANDALONE_OS = frozenset({"alpine", "debian", "ubuntu", "distroless"})
|
|
673
|
+
|
|
674
|
+
_STACK_TITLES = {
|
|
675
|
+
"so": "The operating system alone (no runtime)",
|
|
676
|
+
"node": "Node.js",
|
|
677
|
+
"python": "Python",
|
|
678
|
+
"java": "Java (runtime)",
|
|
679
|
+
"maven": "Java, built with Maven (build + runtime)",
|
|
680
|
+
"gradle": "Java, built with Gradle (build + runtime)",
|
|
681
|
+
"go": "Go",
|
|
682
|
+
"rust": "Rust",
|
|
683
|
+
"php": "PHP",
|
|
684
|
+
"ruby": "Ruby",
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
#: O que distingue cada variante. Sem isto, escolher entre `node-alpine` e
|
|
688
|
+
#: `node-distroless` é adivinhação.
|
|
689
|
+
_TEMPLATE_HINTS = {
|
|
690
|
+
"alpine": "musl, ~5 MB, has a shell",
|
|
691
|
+
"debian": "glibc, stable, has a shell",
|
|
692
|
+
"ubuntu": "glibc, more packages available",
|
|
693
|
+
"distroless": "no shell and no package manager",
|
|
694
|
+
"node": "Debian slim",
|
|
695
|
+
"node-alpine": "musl -- watch out for native modules (sharp, bcrypt)",
|
|
696
|
+
"node-debian": "glibc",
|
|
697
|
+
"node-ubuntu": "glibc",
|
|
698
|
+
"node-distroless": "no shell; runtime only",
|
|
699
|
+
"python": "Debian slim",
|
|
700
|
+
"python-alpine": "musl -- wheels must be musllinux",
|
|
701
|
+
"python-debian": "glibc",
|
|
702
|
+
"python-ubuntu": "glibc",
|
|
703
|
+
"python-distroless": "no shell; interpreter only",
|
|
704
|
+
"java": "Temurin JRE",
|
|
705
|
+
"java-alpine": "Temurin JRE Alpine",
|
|
706
|
+
"java-debian": "Temurin JRE Debian",
|
|
707
|
+
"java-ubuntu": "Temurin JRE Ubuntu",
|
|
708
|
+
"java-distroless": "no shell; JVM only",
|
|
709
|
+
"maven": "builds with Maven, runs on the JRE alone",
|
|
710
|
+
"maven-alpine": "builds with Maven, runs on the JRE alone Alpine",
|
|
711
|
+
"gradle": "builds with Gradle, runs on the JRE alone",
|
|
712
|
+
"gradle-alpine": "builds with Gradle, runs on the JRE alone Alpine",
|
|
713
|
+
"go": "Debian slim",
|
|
714
|
+
"go-alpine": "static musl",
|
|
715
|
+
"go-debian": "glibc",
|
|
716
|
+
"go-distroless": "no shell",
|
|
717
|
+
"go-scratch": "the static binary alone -- the smallest surface there is",
|
|
718
|
+
"rust": "Debian slim",
|
|
719
|
+
"rust-alpine": "musl",
|
|
720
|
+
"rust-debian": "glibc",
|
|
721
|
+
"rust-scratch": "the static binary alone",
|
|
722
|
+
"php": "Debian slim",
|
|
723
|
+
"php-alpine": "musl",
|
|
724
|
+
"php-debian": "glibc",
|
|
725
|
+
"php-ubuntu": "glibc",
|
|
726
|
+
"ruby": "Debian slim",
|
|
727
|
+
"ruby-alpine": "musl",
|
|
728
|
+
"ruby-debian": "glibc",
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
#: Exemplos reais, um por forma de uso. A pergunta que eles respondem é "como
|
|
732
|
+
#: eu escrevo isso", que nenhuma lista de nomes responde sozinha.
|
|
733
|
+
_BUILD_EXAMPLES = (
|
|
734
|
+
"dockerls build -t minha-api:1.0 .",
|
|
735
|
+
" ^ uses the Dockerfile already in the directory",
|
|
736
|
+
"",
|
|
737
|
+
"dockerls build --hardened --base node-alpine -t minha-api:1.0 .",
|
|
738
|
+
" ^ generates a hardened Node-on-Alpine Dockerfile and builds with it",
|
|
739
|
+
"",
|
|
740
|
+
"dockerls build --hardened --base maven-alpine -t minha-api:1.0 --fail-on critical .",
|
|
741
|
+
" ^ Java with Maven: builds with the tool, runs on the JRE alone",
|
|
742
|
+
"",
|
|
743
|
+
"dockerls build --hardened --base go-scratch -t minha-api:1.0 .",
|
|
744
|
+
" ^ the static binary alone: the smallest attack surface there is",
|
|
745
|
+
"",
|
|
746
|
+
"dockerls build --hardened --base ubuntu -t minha-base:1.0 .",
|
|
747
|
+
" ^ the operating system only, with no language runtime",
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _run_interactive_wizard(use_case: BuildImageUseCase, path: str) -> BuildImageResponse:
|
|
752
|
+
"""Executa wizard interativo completo com questionário aprofundado."""
|
|
753
|
+
console.print(
|
|
754
|
+
Panel(
|
|
755
|
+
"[bold cyan]DockerLs Interactive Build Wizard[/bold cyan]\n"
|
|
756
|
+
"[dim]Step-by-step setup aimed at security and zero vulnerabilities[/dim]",
|
|
757
|
+
expand=False,
|
|
758
|
+
)
|
|
759
|
+
)
|
|
760
|
+
console.print()
|
|
761
|
+
|
|
762
|
+
available = HardeningTemplates().list_templates() or [
|
|
763
|
+
"node",
|
|
764
|
+
"python",
|
|
765
|
+
"go",
|
|
766
|
+
"rust",
|
|
767
|
+
"java",
|
|
768
|
+
"php",
|
|
769
|
+
]
|
|
770
|
+
|
|
771
|
+
# 1. Ecossistema / Linguagem
|
|
772
|
+
console.print("[bold yellow]? 1. Which ecosystem / language is your application?[/bold yellow]")
|
|
773
|
+
stacks = ["node", "python", "go", "java", "rust", "php", "other"]
|
|
774
|
+
for i, s in enumerate(stacks, 1):
|
|
775
|
+
console.print(f" {i}. {s}")
|
|
776
|
+
stack_choice = _prompt_choice(stacks, "1")
|
|
777
|
+
|
|
778
|
+
# 2. Versão recomendada e particularidades
|
|
779
|
+
version_options = {
|
|
780
|
+
"node": ["22.x LTS (Recommended)", "20.x LTS", "18.x", "custom"],
|
|
781
|
+
"python": ["3.12 (Recommended)", "3.13", "3.11", "custom"],
|
|
782
|
+
"go": ["1.23 (Recommended)", "1.24", "1.22", "custom"],
|
|
783
|
+
"java": ["21 LTS (Eclipse Temurin)", "17 LTS", "custom"],
|
|
784
|
+
"rust": ["1.82 (Alpine musl static)", "latest", "custom"],
|
|
785
|
+
"php": ["8.3 FPM/CLI", "8.2", "custom"],
|
|
786
|
+
}
|
|
787
|
+
opts = version_options.get(stack_choice, ["latest", "custom"])
|
|
788
|
+
console.print(f"\n[bold yellow]? 2. Which version of {stack_choice} do you want?[/bold yellow]")
|
|
789
|
+
for i, opt in enumerate(opts, 1):
|
|
790
|
+
console.print(f" {i}. {opt}")
|
|
791
|
+
_ = _prompt_choice(opts, "1")
|
|
792
|
+
|
|
793
|
+
# 3. Base distribution
|
|
794
|
+
console.print("\n[bold yellow]? 3. Which base distribution do you prefer?[/bold yellow]")
|
|
795
|
+
distros = [
|
|
796
|
+
"alpine (Alpine Linux - Ultra-lightweight musl)",
|
|
797
|
+
"debian (Debian Bookworm Slim - glibc)",
|
|
798
|
+
"ubuntu (Ubuntu 24.04 LTS - highest package compatibility)",
|
|
799
|
+
"distroless (Google Distroless - no shell, zero OS CVEs)",
|
|
800
|
+
"scratch (plain scratch, for static binaries)",
|
|
801
|
+
]
|
|
802
|
+
for i, d in enumerate(distros, 1):
|
|
803
|
+
console.print(f" {i}. {d}")
|
|
804
|
+
distro_raw = _prompt_choice(distros, "1")
|
|
805
|
+
distro_key = distro_raw.split()[0].lower()
|
|
806
|
+
|
|
807
|
+
# 4. Usar template hardened
|
|
808
|
+
console.print(
|
|
809
|
+
"\n[bold yellow]? 4. Use a multi-stage template with a non-root user?[/bold yellow]"
|
|
810
|
+
)
|
|
811
|
+
console.print(" 1. yes (recommended - reduces the attack surface)")
|
|
812
|
+
console.print(" 2. no (use the directory default Dockerfile)")
|
|
813
|
+
use_hardened = _prompt_choice(["yes", "no"], "1") == "yes"
|
|
814
|
+
|
|
815
|
+
# 5. Dependências do SO / build nativo
|
|
816
|
+
console.print(
|
|
817
|
+
"\n[bold yellow]? 5. Does your application need native OS dependencies?[/bold yellow]"
|
|
818
|
+
)
|
|
819
|
+
deps_opts = [
|
|
820
|
+
"none (default runtime only)",
|
|
821
|
+
"build-essential / gcc / make",
|
|
822
|
+
"libpq (PostgreSQL client)",
|
|
823
|
+
"openssl / ca-certificates",
|
|
824
|
+
]
|
|
825
|
+
for i, dep in enumerate(deps_opts, 1):
|
|
826
|
+
console.print(f" {i}. {dep}")
|
|
827
|
+
_ = _prompt_choice(deps_opts, "1")
|
|
828
|
+
|
|
829
|
+
# 6. Portas
|
|
830
|
+
default_port = (
|
|
831
|
+
"3000"
|
|
832
|
+
if stack_choice == "node"
|
|
833
|
+
else "8000"
|
|
834
|
+
if stack_choice in ("python", "php")
|
|
835
|
+
else "8080"
|
|
836
|
+
)
|
|
837
|
+
port_input = (
|
|
838
|
+
console.input(f"\n[bold yellow]? 6. Application port [{default_port}]: [/bold yellow]")
|
|
839
|
+
or default_port
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
# 7. Scan pós-build
|
|
843
|
+
console.print("\n[bold yellow]? 7. Run a vulnerability scan after the build?[/bold yellow]")
|
|
844
|
+
scan = _prompt_choice(["yes", "no"], "1") == "yes"
|
|
845
|
+
|
|
846
|
+
# 8. Ciclo de auto-remediação até zero vulnerabilidades
|
|
847
|
+
console.print("\n[bold yellow]? 8. Loop until ZERO vulnerabilities?[/bold yellow]")
|
|
848
|
+
console.print(" 1. yes (patches until the CVEs are gone)")
|
|
849
|
+
console.print(" 2. no (only report the vulnerabilities found)")
|
|
850
|
+
zero_vulns = _prompt_choice(["yes", "no"], "1") == "yes"
|
|
851
|
+
|
|
852
|
+
# 9. Tag da imagem
|
|
853
|
+
tag_input = (
|
|
854
|
+
console.input("\n[bold yellow]? 9. Docker image tag [app:latest]: [/bold yellow]")
|
|
855
|
+
or "app:latest"
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
# 10. Push para registro
|
|
859
|
+
console.print("\n[bold yellow]? 10. Publish (docker push) once it passes?[/bold yellow]")
|
|
860
|
+
push_choice = _prompt_choice(["no", "dockerhub", "ghcr", "harbor"], "1")
|
|
861
|
+
|
|
862
|
+
# Determinar melhor template com base na combinação Stack + Distro
|
|
863
|
+
candidate_key = f"{stack_choice}-{distro_key}"
|
|
864
|
+
if candidate_key in available:
|
|
865
|
+
base_template = candidate_key
|
|
866
|
+
elif stack_choice in available:
|
|
867
|
+
base_template = stack_choice
|
|
868
|
+
elif distro_key in available:
|
|
869
|
+
base_template = distro_key
|
|
870
|
+
else:
|
|
871
|
+
base_template = "node-alpine"
|
|
872
|
+
|
|
873
|
+
request = BuildImageRequest(
|
|
874
|
+
context_path=path,
|
|
875
|
+
tag=tag_input,
|
|
876
|
+
hardened=use_hardened,
|
|
877
|
+
base_template=base_template if use_hardened else None,
|
|
878
|
+
scan=scan,
|
|
879
|
+
auto_remediate=zero_vulns,
|
|
880
|
+
target_zero_vulns=zero_vulns,
|
|
881
|
+
push=push_choice != "no",
|
|
882
|
+
labels={"app.port": port_input, "dockerls.managed": "true"},
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
return use_case.execute(request)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _prompt_choice(options: list[str], default: str = "1") -> str:
|
|
889
|
+
"""Solicita a escolha do usuário."""
|
|
890
|
+
while True:
|
|
891
|
+
try:
|
|
892
|
+
choice = console.input(f"\nChoice [{default}]: ")
|
|
893
|
+
if not choice:
|
|
894
|
+
choice = default
|
|
895
|
+
idx = int(choice) - 1
|
|
896
|
+
if 0 <= idx < len(options):
|
|
897
|
+
return options[idx]
|
|
898
|
+
except ValueError:
|
|
899
|
+
continue
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _print_table_output(response: BuildImageResponse, report_file: str | None = None) -> None:
|
|
903
|
+
"""Imprime resultado formatado em tabela."""
|
|
904
|
+
# Nenhuma imagem construída: o resultado é a validação, e é ela que
|
|
905
|
+
# precisa aparecer -- com os checks, não só com um veredito.
|
|
906
|
+
if response.image_tag is None:
|
|
907
|
+
_print_validation_output(response, report_file)
|
|
908
|
+
return
|
|
909
|
+
|
|
910
|
+
_print_build_output(response, report_file)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _print_validation_output(response: BuildImageResponse, report_file: str | None) -> None:
|
|
914
|
+
if response.validation is not None:
|
|
915
|
+
render_validation_report(
|
|
916
|
+
console,
|
|
917
|
+
response.validation,
|
|
918
|
+
analysis=response.analysis,
|
|
919
|
+
suggestions=list(response.recommendations) or None,
|
|
920
|
+
title="Dockerfile Validation",
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
if response.success:
|
|
924
|
+
console.print(
|
|
925
|
+
Panel(
|
|
926
|
+
"[bold green]Validation Passed[/bold green]\n"
|
|
927
|
+
"[dim]No blocking policy violations found[/dim]",
|
|
928
|
+
expand=False,
|
|
929
|
+
)
|
|
930
|
+
)
|
|
931
|
+
else:
|
|
932
|
+
console.print(
|
|
933
|
+
Panel(
|
|
934
|
+
f"[bold red]Validation Failed[/bold red]\n\n"
|
|
935
|
+
f"[red]{response.error or 'Dockerfile validation failed'}[/red]",
|
|
936
|
+
expand=False,
|
|
937
|
+
)
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
_print_policy_violations(response.policy_violations)
|
|
941
|
+
_write_report_file(response.report, report_file)
|
|
942
|
+
console.print()
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _print_build_output(response: BuildImageResponse, report_file: str | None) -> None:
|
|
946
|
+
if not response.success:
|
|
947
|
+
console.print(
|
|
948
|
+
Panel(
|
|
949
|
+
f"[bold red]Build Failed[/bold red]\n\n"
|
|
950
|
+
f"[red]{response.error or 'Build failed'}[/red]",
|
|
951
|
+
expand=False,
|
|
952
|
+
)
|
|
953
|
+
)
|
|
954
|
+
_print_inheritance(response.inheritance)
|
|
955
|
+
_print_policy_violations(response.policy_violations)
|
|
956
|
+
_write_report_file(response.report, report_file)
|
|
957
|
+
return
|
|
958
|
+
|
|
959
|
+
console.print(
|
|
960
|
+
Panel(
|
|
961
|
+
f"[bold green]Build Successful[/bold green]\n[dim]{response.image_tag}[/dim]",
|
|
962
|
+
expand=False,
|
|
963
|
+
)
|
|
964
|
+
)
|
|
965
|
+
console.print()
|
|
966
|
+
|
|
967
|
+
report = response.report
|
|
968
|
+
if report is not None:
|
|
969
|
+
_print_report(report)
|
|
970
|
+
_write_report_file(report, report_file)
|
|
971
|
+
|
|
972
|
+
_print_inheritance(response.inheritance)
|
|
973
|
+
|
|
974
|
+
if response.provenance is not None:
|
|
975
|
+
_print_provenance(response.provenance)
|
|
976
|
+
|
|
977
|
+
if response.recommendations:
|
|
978
|
+
console.print(Panel("[bold yellow]Hardening Suggestions[/bold yellow]", expand=False))
|
|
979
|
+
for i, rec in enumerate(response.recommendations[:3], 1):
|
|
980
|
+
console.print(f"\n{i}. [bold]{rec.title}[/bold]")
|
|
981
|
+
console.print(f" [dim]{rec.description}[/dim]")
|
|
982
|
+
console.print(f" Fix: [green]{rec.suggested_fix}[/green]")
|
|
983
|
+
|
|
984
|
+
console.print()
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _print_report(report: BuildReport) -> None:
|
|
988
|
+
tier_colors = {"A": "green", "B": "yellow", "C": "yellow", "D": "red", "F": "red"}
|
|
989
|
+
tier_color = tier_colors.get(report.security_tier, "white")
|
|
990
|
+
|
|
991
|
+
console.print(
|
|
992
|
+
Panel(
|
|
993
|
+
f"[bold]Security Score: {report.security_score}/100[/bold]\n"
|
|
994
|
+
f"Tier: [{tier_color} bold]{report.security_tier}[/{tier_color} bold]",
|
|
995
|
+
expand=False,
|
|
996
|
+
)
|
|
997
|
+
)
|
|
998
|
+
console.print()
|
|
999
|
+
|
|
1000
|
+
validation = report.validation
|
|
1001
|
+
console.print(
|
|
1002
|
+
f"Validation: [green]{validation.get('passed', 0)} passed[/green] | "
|
|
1003
|
+
f"[yellow]{validation.get('warnings', 0)} warnings[/yellow] | "
|
|
1004
|
+
f"[red]{validation.get('errors', 0)} errors[/red]"
|
|
1005
|
+
)
|
|
1006
|
+
console.print()
|
|
1007
|
+
|
|
1008
|
+
if report.scan_results:
|
|
1009
|
+
console.print(Panel("[bold magenta]Security Scan Results[/bold magenta]", expand=False))
|
|
1010
|
+
scan_data = next(iter(report.scan_results.values()))
|
|
1011
|
+
console.print(f" CRITICAL: [red]{scan_data.get('critical', 0)}[/red]")
|
|
1012
|
+
console.print(f" HIGH: [red]{scan_data.get('high', 0)}[/red]")
|
|
1013
|
+
console.print(f" MEDIUM: [yellow]{scan_data.get('medium', 0)}[/yellow]")
|
|
1014
|
+
console.print(f" LOW: [dim]{scan_data.get('low', 0)}[/dim]")
|
|
1015
|
+
console.print()
|
|
1016
|
+
|
|
1017
|
+
if report.remediation_history:
|
|
1018
|
+
console.print(Panel("[bold green]Auto-Remediation Summary[/bold green]", expand=False))
|
|
1019
|
+
for item in report.remediation_history:
|
|
1020
|
+
round_num = item.get("round", 1)
|
|
1021
|
+
actions = item.get("actions", [])
|
|
1022
|
+
crit_b = item.get("critical_before", 0)
|
|
1023
|
+
crit_a = item.get("critical_after", 0)
|
|
1024
|
+
total_b = item.get("total_before", 0)
|
|
1025
|
+
total_a = item.get("total_after", 0)
|
|
1026
|
+
console.print(
|
|
1027
|
+
f" [bold cyan]Round {round_num}:[/bold cyan] "
|
|
1028
|
+
f"Total Vulns: {total_b} -> [green]{total_a}[/green] | "
|
|
1029
|
+
f"Critical: {crit_b} -> [green]{crit_a}[/green]"
|
|
1030
|
+
)
|
|
1031
|
+
for action in actions:
|
|
1032
|
+
console.print(f" - [dim]{action}[/dim]")
|
|
1033
|
+
console.print()
|
|
1034
|
+
|
|
1035
|
+
if report.recommendations:
|
|
1036
|
+
console.print(Panel("[bold yellow]Recommendations[/bold yellow]", expand=False))
|
|
1037
|
+
priority_colors = {"CRITICAL": "red", "HIGH": "red", "MEDIUM": "yellow", "LOW": "dim"}
|
|
1038
|
+
for i, rec in enumerate(report.recommendations[:5], 1):
|
|
1039
|
+
priority = str(rec.get("priority", "MEDIUM"))
|
|
1040
|
+
priority_color = priority_colors.get(priority, "white")
|
|
1041
|
+
console.print(f"\n[{priority_color}]#{i}. {rec.get('title', '-')}[/{priority_color}]")
|
|
1042
|
+
console.print(f" [dim]{rec.get('reason', '-')}[/dim]")
|
|
1043
|
+
console.print(f" Fix: [green]{rec.get('suggested', '-')}[/green]")
|
|
1044
|
+
console.print()
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _write_report_file(report: BuildReport | None, report_file: str | None) -> None:
|
|
1048
|
+
if report is None or not report_file:
|
|
1049
|
+
return
|
|
1050
|
+
try:
|
|
1051
|
+
_save_report(report, report_file)
|
|
1052
|
+
except OSError as e:
|
|
1053
|
+
# An unwritable report destination is user error (bad path, no
|
|
1054
|
+
# permission), not a crash -- and it must not mask the build result
|
|
1055
|
+
# that was already printed above.
|
|
1056
|
+
console.print(f"\n[red]Could not write report to {report_file}:[/red] {e}")
|
|
1057
|
+
return
|
|
1058
|
+
console.print(f"\nReport saved: [cyan]{report_file}[/cyan]")
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _report_dict(report: BuildReport) -> dict[str, Any]:
|
|
1062
|
+
return {
|
|
1063
|
+
"build_id": report.build_id,
|
|
1064
|
+
"timestamp": report.timestamp,
|
|
1065
|
+
"image": report.image,
|
|
1066
|
+
"dockerfile_path": report.dockerfile_path,
|
|
1067
|
+
"security_score": report.security_score,
|
|
1068
|
+
"security_tier": report.security_tier,
|
|
1069
|
+
"validation": report.validation,
|
|
1070
|
+
"scan_results": report.scan_results,
|
|
1071
|
+
"recommendations": report.recommendations,
|
|
1072
|
+
"build_metadata": report.build_metadata,
|
|
1073
|
+
"remediation_history": report.remediation_history,
|
|
1074
|
+
"auto_remediated": report.auto_remediated,
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _print_json_output(
|
|
1079
|
+
response: BuildImageResponse,
|
|
1080
|
+
output_file: str | None = None,
|
|
1081
|
+
*,
|
|
1082
|
+
signature: SignatureResult | None = None,
|
|
1083
|
+
) -> None:
|
|
1084
|
+
"""Imprime saída JSON (CI mode).
|
|
1085
|
+
|
|
1086
|
+
Vai para stdout via `typer.echo`, não pelo console do Rich: em CI o
|
|
1087
|
+
consumidor é um parser, e cor ou quebra de linha por largura de terminal
|
|
1088
|
+
quebrariam o JSON.
|
|
1089
|
+
"""
|
|
1090
|
+
output_data: dict[str, Any] = {
|
|
1091
|
+
"status": "SUCCESS" if response.success else "FAILED",
|
|
1092
|
+
"exit_code": response.exit_code,
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
# O relatório entra sempre que existe -- inclusive numa validação
|
|
1096
|
+
# reprovada, que é justamente quando o CI precisa saber o que falhou.
|
|
1097
|
+
if response.report is not None:
|
|
1098
|
+
output_data["report"] = _report_dict(response.report)
|
|
1099
|
+
# A procedência entra no JSON sempre que existe: é o que um portão de
|
|
1100
|
+
# supply chain lê para decidir, e ele não lê tabela de terminal.
|
|
1101
|
+
if response.provenance is not None:
|
|
1102
|
+
output_data["provenance"] = response.provenance.to_dict()
|
|
1103
|
+
if signature is not None:
|
|
1104
|
+
output_data["signature"] = signature.to_dict()
|
|
1105
|
+
if response.inheritance is not None:
|
|
1106
|
+
output_data["inheritance"] = response.inheritance.to_dict()
|
|
1107
|
+
if response.policy_violations:
|
|
1108
|
+
output_data["policy_violations"] = [v.to_dict() for v in response.policy_violations]
|
|
1109
|
+
if response.error:
|
|
1110
|
+
output_data["error"] = response.error
|
|
1111
|
+
|
|
1112
|
+
json_output = json.dumps(output_data, indent=2)
|
|
1113
|
+
|
|
1114
|
+
if output_file:
|
|
1115
|
+
try:
|
|
1116
|
+
Path(output_file).write_text(json_output, encoding="utf-8")
|
|
1117
|
+
except OSError as e:
|
|
1118
|
+
console.print(f"[red]Could not write {output_file}:[/red] {e}")
|
|
1119
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
1120
|
+
console.print(f"Report saved to {output_file}", style="dim")
|
|
1121
|
+
else:
|
|
1122
|
+
typer.echo(json_output)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _save_report(report: BuildReport, filepath: str) -> None:
|
|
1126
|
+
"""Salva relatório em arquivo."""
|
|
1127
|
+
path = Path(filepath)
|
|
1128
|
+
|
|
1129
|
+
if path.suffix.lower() in (".html", ".htm"):
|
|
1130
|
+
path.write_text(_render_html_report(report), encoding="utf-8")
|
|
1131
|
+
return
|
|
1132
|
+
|
|
1133
|
+
path.write_text(json.dumps(_report_dict(report), indent=2), encoding="utf-8")
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _render_html_report(report: BuildReport) -> str:
|
|
1137
|
+
score_color = "#22c55e" if report.security_score >= 75 else "#ef4444"
|
|
1138
|
+
tier_color = "#22c55e" if report.security_tier == "A" else "#ef4444"
|
|
1139
|
+
validation = report.validation
|
|
1140
|
+
# Every value below originates outside this process -- `--tag`, the
|
|
1141
|
+
# Dockerfile path, the tier string. Interpolated raw, a tag like
|
|
1142
|
+
# `x"><script>...` turned the report into an execution vector for whoever
|
|
1143
|
+
# opens it. The `export --format html` path already escaped; this one did
|
|
1144
|
+
# not, which is exactly the kind of split a security tool cannot afford.
|
|
1145
|
+
image = _esc(report.image)
|
|
1146
|
+
dockerfile_path = _esc(report.dockerfile_path)
|
|
1147
|
+
timestamp = _esc(report.timestamp)
|
|
1148
|
+
tier = _esc(report.security_tier)
|
|
1149
|
+
|
|
1150
|
+
html = f"""<!DOCTYPE html>
|
|
1151
|
+
<html>
|
|
1152
|
+
<head>
|
|
1153
|
+
<title>DockerLs Build Report - {image or dockerfile_path}</title>
|
|
1154
|
+
<style>
|
|
1155
|
+
body {{ font-family: Arial, sans-serif; margin: 40px; }}
|
|
1156
|
+
.score {{ font-size: 48px; font-weight: bold; color: {score_color}; }}
|
|
1157
|
+
.tier {{ font-size: 24px; color: {tier_color}; }}
|
|
1158
|
+
table {{ border-collapse: collapse; width: 100%; margin: 20px 0; }}
|
|
1159
|
+
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
|
|
1160
|
+
th {{ background-color: #f3f4f6; }}
|
|
1161
|
+
.critical {{ color: #dc2626; }}
|
|
1162
|
+
.high {{ color: #dc2626; }}
|
|
1163
|
+
.medium {{ color: #f59e0b; }}
|
|
1164
|
+
.low {{ color: #6b7280; }}
|
|
1165
|
+
</style>
|
|
1166
|
+
</head>
|
|
1167
|
+
<body>
|
|
1168
|
+
<h1>DockerLs Build Report</h1>
|
|
1169
|
+
<p><strong>Image:</strong> {image or "(not built)"}</p>
|
|
1170
|
+
<p><strong>Dockerfile:</strong> {dockerfile_path}</p>
|
|
1171
|
+
<p><strong>Timestamp:</strong> {timestamp}</p>
|
|
1172
|
+
|
|
1173
|
+
<h2>Security Assessment</h2>
|
|
1174
|
+
<div class="score">{_int(report.security_score)}/100</div>
|
|
1175
|
+
<div class="tier">Tier: {tier}</div>
|
|
1176
|
+
|
|
1177
|
+
<h2>Validation Results</h2>
|
|
1178
|
+
<table>
|
|
1179
|
+
<tr><th>Passed</th><td>{_int(validation.get("passed", 0))}</td></tr>
|
|
1180
|
+
<tr><th>Warnings</th><td>{_int(validation.get("warnings", 0))}</td></tr>
|
|
1181
|
+
<tr><th>Errors</th><td>{_int(validation.get("errors", 0))}</td></tr>
|
|
1182
|
+
</table>
|
|
1183
|
+
|
|
1184
|
+
<h2>Vulnerability Scan</h2>
|
|
1185
|
+
"""
|
|
1186
|
+
|
|
1187
|
+
if report.scan_results:
|
|
1188
|
+
scan = next(iter(report.scan_results.values()))
|
|
1189
|
+
html += f"""
|
|
1190
|
+
<table>
|
|
1191
|
+
<tr><th>Severity</th><th>Count</th></tr>
|
|
1192
|
+
<tr><td class="critical">Critical</td><td>{_int(scan.get("critical", 0))}</td></tr>
|
|
1193
|
+
<tr><td class="high">High</td><td>{_int(scan.get("high", 0))}</td></tr>
|
|
1194
|
+
<tr><td class="medium">Medium</td><td>{_int(scan.get("medium", 0))}</td></tr>
|
|
1195
|
+
<tr><td class="low">Low</td><td>{_int(scan.get("low", 0))}</td></tr>
|
|
1196
|
+
</table>
|
|
1197
|
+
"""
|
|
1198
|
+
else:
|
|
1199
|
+
html += " <p>No scan was run.</p>\n"
|
|
1200
|
+
|
|
1201
|
+
if report.remediation_history:
|
|
1202
|
+
html += """
|
|
1203
|
+
<h2>Auto-Remediation Summary</h2>
|
|
1204
|
+
<table>
|
|
1205
|
+
<tr>
|
|
1206
|
+
<th>Round</th>
|
|
1207
|
+
<th>Fixes Applied</th>
|
|
1208
|
+
<th>Critical (Before → After)</th>
|
|
1209
|
+
<th>Total (Before → After)</th>
|
|
1210
|
+
</tr>
|
|
1211
|
+
"""
|
|
1212
|
+
for item in report.remediation_history:
|
|
1213
|
+
round_num = _int(item.get("round", 1))
|
|
1214
|
+
actions_str = _esc("<br>".join(item.get("actions", [])))
|
|
1215
|
+
cb = _int(item.get("critical_before", 0))
|
|
1216
|
+
ca = _int(item.get("critical_after", 0))
|
|
1217
|
+
tb = _int(item.get("total_before", 0))
|
|
1218
|
+
ta = _int(item.get("total_after", 0))
|
|
1219
|
+
html += (
|
|
1220
|
+
f" <tr><td>{round_num}</td><td>{actions_str}</td>"
|
|
1221
|
+
f"<td>{cb} → {ca}</td><td>{tb} → {ta}</td></tr>\n"
|
|
1222
|
+
)
|
|
1223
|
+
html += " </table>\n"
|
|
1224
|
+
|
|
1225
|
+
return (
|
|
1226
|
+
html
|
|
1227
|
+
+ """
|
|
1228
|
+
</body>
|
|
1229
|
+
</html>"""
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _esc(value: object) -> str:
|
|
1234
|
+
"""HTML-escape a report value, quotes included, for attribute safety."""
|
|
1235
|
+
return html_escape(str(value), quote=True)
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _int(value: object) -> int:
|
|
1239
|
+
"""Counts come from scanner JSON, so they are numbers by convention, not
|
|
1240
|
+
by guarantee. Coercing keeps a non-numeric value from reaching the page
|
|
1241
|
+
as markup."""
|
|
1242
|
+
if isinstance(value, bool) or not isinstance(value, int | float | str):
|
|
1243
|
+
return 0
|
|
1244
|
+
try:
|
|
1245
|
+
return int(value)
|
|
1246
|
+
except (TypeError, ValueError):
|
|
1247
|
+
return 0
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _tag_part(tag: str | None) -> str:
|
|
1251
|
+
"""A tag de `nome:tag`, ou `latest` quando não há uma.
|
|
1252
|
+
|
|
1253
|
+
O destino recebe host e caminho; a tag vem daqui, de um lugar só, para
|
|
1254
|
+
não haver duas fontes discordando sobre qual versão está sendo publicada.
|
|
1255
|
+
"""
|
|
1256
|
+
value = (tag or "").strip()
|
|
1257
|
+
if ":" in value:
|
|
1258
|
+
return value.rpartition(":")[2] or "latest"
|
|
1259
|
+
return "latest"
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _print_provenance(provenance: BuildProvenance) -> None:
|
|
1263
|
+
"""Os hashes de antes e depois, e o que a comparação entre eles diz.
|
|
1264
|
+
|
|
1265
|
+
Impresso mesmo quando tudo bate: o valor de uma cadeia de fornecimento
|
|
1266
|
+
está em ser vista rotineiramente, não só quando quebra -- quem nunca leu
|
|
1267
|
+
o registro íntegro não reconhece o rompido.
|
|
1268
|
+
"""
|
|
1269
|
+
status = provenance.status
|
|
1270
|
+
colors = {
|
|
1271
|
+
ProvenanceStatus.VERIFIED: "green",
|
|
1272
|
+
ProvenanceStatus.INCOMPLETE: "yellow",
|
|
1273
|
+
ProvenanceStatus.INPUT_CHANGED: "red",
|
|
1274
|
+
}
|
|
1275
|
+
color = colors.get(status, "white")
|
|
1276
|
+
console.print(Panel(f"[bold {color}]Supply chain: {status}[/bold {color}]", expand=False))
|
|
1277
|
+
console.print(f" [dim]{safe(provenance.explain())}[/dim]\n")
|
|
1278
|
+
|
|
1279
|
+
source = provenance.source
|
|
1280
|
+
console.print("[bold]INPUT[/bold] [dim](measured before the build)[/dim]")
|
|
1281
|
+
console.print(f" Dockerfile {safe(source.dockerfile) or '[dim]not digested[/dim]'}")
|
|
1282
|
+
console.print(
|
|
1283
|
+
f" Contexto {safe(source.context) or '[dim]not digested[/dim]'}"
|
|
1284
|
+
f" [dim]({source.context_files} files)[/dim]"
|
|
1285
|
+
)
|
|
1286
|
+
if source.git_revision:
|
|
1287
|
+
dirty = " [yellow](dirty tree)[/yellow]" if source.git_dirty else ""
|
|
1288
|
+
console.print(f" Commit {safe(source.git_revision)}{dirty}")
|
|
1289
|
+
for reference, digest in source.base_images.items():
|
|
1290
|
+
pinned = safe(digest) if digest else "[yellow]moving tag, no digest[/yellow]"
|
|
1291
|
+
console.print(f" Base {safe(reference)} -> {pinned}")
|
|
1292
|
+
|
|
1293
|
+
artifact = provenance.artifact
|
|
1294
|
+
console.print("\n[bold]OUTPUT[/bold] [dim](measured after the build)[/dim]")
|
|
1295
|
+
console.print(f" Image {safe(artifact.image_id) or '[dim]unknown[/dim]'}")
|
|
1296
|
+
if artifact.repo_digest:
|
|
1297
|
+
console.print(f" Manifesto {safe(artifact.repo_digest)}")
|
|
1298
|
+
if artifact.published_reference:
|
|
1299
|
+
console.print(f" Publicada {safe(artifact.published_reference)}")
|
|
1300
|
+
console.print()
|