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,429 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.measure import Measurement
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from dockerls.application.dto.analysis import AnalysisResult
|
|
13
|
+
from dockerls.application.services.remediation import (
|
|
14
|
+
build_remediation_plan,
|
|
15
|
+
render_dockerfile_patch,
|
|
16
|
+
)
|
|
17
|
+
from dockerls.cli.dependencies import build_analyze_use_case
|
|
18
|
+
from dockerls.cli.scan_failure import describe_scan_failure
|
|
19
|
+
from dockerls.cli.text import safe
|
|
20
|
+
from dockerls.cli.vulnerability_view import (
|
|
21
|
+
count_by_origin,
|
|
22
|
+
exploit_urls,
|
|
23
|
+
npm_remediation_hint,
|
|
24
|
+
origin_label,
|
|
25
|
+
sort_by_severity,
|
|
26
|
+
threat_label,
|
|
27
|
+
threat_style,
|
|
28
|
+
)
|
|
29
|
+
from dockerls.domain.entities.vulnerability import PackageOrigin, Vulnerability
|
|
30
|
+
from dockerls.exit_codes import EXIT_ERROR, EXIT_OK, EXIT_POLICY
|
|
31
|
+
from dockerls.exporters.factory import ExporterFactory
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from dockerls.application.dto.analysis import ImageAnalysis
|
|
35
|
+
from dockerls.application.services.remediation import RemediationPlan
|
|
36
|
+
from dockerls.domain.entities.scan_result import ScanResult
|
|
37
|
+
|
|
38
|
+
console = Console()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_FORMATS = ("table", "json", "sarif")
|
|
42
|
+
|
|
43
|
+
#: Limiares de `--fail-on`, do mais severo para o mais brando. Cada um reprova
|
|
44
|
+
#: também tudo que for pior que ele -- mesma semântica de `build --fail-on`,
|
|
45
|
+
#: para que a mesma palavra não signifique duas coisas na mesma ferramenta.
|
|
46
|
+
FAIL_ON_THRESHOLDS = ("critical", "high", "medium", "low")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def analyze(
|
|
50
|
+
image: str = typer.Argument(help="Full image reference (e.g., node:22-alpine)"),
|
|
51
|
+
output_format: str = typer.Option(
|
|
52
|
+
"table", "--format", "-f", help="Output format: table, json or sarif"
|
|
53
|
+
),
|
|
54
|
+
output: str = typer.Option(
|
|
55
|
+
"", "--output", "-o", help="Write the report (or, with --fix, the patch) to a file"
|
|
56
|
+
),
|
|
57
|
+
fix: bool = typer.Option(
|
|
58
|
+
False, "--fix", help="Emit a Dockerfile patch that remediates the findings"
|
|
59
|
+
),
|
|
60
|
+
fail_on: str | None = typer.Option(
|
|
61
|
+
None,
|
|
62
|
+
"--fail-on",
|
|
63
|
+
help="Exit with the policy code when findings at/above this severity exist",
|
|
64
|
+
),
|
|
65
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
|
|
66
|
+
wide: bool = typer.Option(
|
|
67
|
+
False, "--wide", help="Render the table without truncating any column"
|
|
68
|
+
),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Deep-analyze a specific Docker image tag."""
|
|
71
|
+
if no_color:
|
|
72
|
+
console.no_color = True
|
|
73
|
+
if output_format not in _FORMATS:
|
|
74
|
+
console.print(
|
|
75
|
+
f"[red]Error:[/red] unsupported --format {output_format!r}. "
|
|
76
|
+
f"Use one of: {', '.join(_FORMATS)}"
|
|
77
|
+
)
|
|
78
|
+
raise typer.Exit(EXIT_ERROR)
|
|
79
|
+
# `--fix` produz um Dockerfile; `json`/`sarif` produzem um relatório. Um
|
|
80
|
+
# `--output` só pode receber um dos dois, e adivinhar qual seria pior que
|
|
81
|
+
# recusar.
|
|
82
|
+
if fix and output_format != "table":
|
|
83
|
+
console.print(
|
|
84
|
+
f"[red]Error:[/red] --fix produces a Dockerfile patch and cannot be combined "
|
|
85
|
+
f"with --format {output_format}. Run it on its own, or with --output."
|
|
86
|
+
)
|
|
87
|
+
raise typer.Exit(EXIT_ERROR)
|
|
88
|
+
# Um limiar que a ferramenta não entende viraria um portão aberto com cara
|
|
89
|
+
# de fechado -- rejeitado antes de qualquer scan começar.
|
|
90
|
+
if fail_on is not None and fail_on.strip().lower() not in FAIL_ON_THRESHOLDS:
|
|
91
|
+
console.print(
|
|
92
|
+
f"[red]Error:[/red] invalid --fail-on {fail_on!r}. "
|
|
93
|
+
f"Use one of: {', '.join(FAIL_ON_THRESHOLDS)}"
|
|
94
|
+
)
|
|
95
|
+
raise typer.Exit(EXIT_ERROR)
|
|
96
|
+
asyncio.run(
|
|
97
|
+
_analyze(
|
|
98
|
+
image,
|
|
99
|
+
wide=wide,
|
|
100
|
+
output_format=output_format,
|
|
101
|
+
output=output,
|
|
102
|
+
fail_on=fail_on,
|
|
103
|
+
fix=fix,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# A CVE ID is the primary key of a finding: "CVE-2026…" identifies nothing and
|
|
109
|
+
# cannot be looked up. `CVE-YYYY-NNNNN` is 14 cells, so the column reserves
|
|
110
|
+
# that much and the flexible columns (package, versions) give up width first.
|
|
111
|
+
_CVE_MIN_WIDTH = 14
|
|
112
|
+
|
|
113
|
+
# Used to measure what the table would like to be, free of the terminal's
|
|
114
|
+
# width -- Measurement clamps to the console otherwise, which makes an
|
|
115
|
+
# overflowing table look like a table that fits.
|
|
116
|
+
_UNBOUNDED_WIDTH = 10_000
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _analyze(
|
|
120
|
+
image: str,
|
|
121
|
+
wide: bool = False,
|
|
122
|
+
output_format: str = "table",
|
|
123
|
+
output: str = "",
|
|
124
|
+
fail_on: str | None = None,
|
|
125
|
+
fix: bool = False,
|
|
126
|
+
) -> None:
|
|
127
|
+
use_case = await build_analyze_use_case()
|
|
128
|
+
try:
|
|
129
|
+
result = await use_case.execute(image)
|
|
130
|
+
except ValueError as e:
|
|
131
|
+
console.print(f"[red]Scan failed: {e}[/red]")
|
|
132
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
133
|
+
finally:
|
|
134
|
+
# The scanner and the repository's connection pool are held for the
|
|
135
|
+
# length of the run; releasing them is the caller's job. Rendering
|
|
136
|
+
# below needs only `result`, so this is the right moment.
|
|
137
|
+
await use_case.close()
|
|
138
|
+
|
|
139
|
+
if not result.scan.is_verified:
|
|
140
|
+
# Sem scan não há veredito. Sair 0 aqui deixaria um portão de CI
|
|
141
|
+
# passar uma imagem que ninguém mediu.
|
|
142
|
+
#
|
|
143
|
+
# A causa vai resumida: o stderr cru do Trivy para uma tag
|
|
144
|
+
# inexistente ocupa várias linhas e menciona o socket do Docker,
|
|
145
|
+
# que este modo de scan nem usa. O texto completo continua no
|
|
146
|
+
# arquivo de log e em `--format json`.
|
|
147
|
+
console.print(
|
|
148
|
+
f"[red]Scan did not complete for {safe(result.image.full_reference)}:[/red] "
|
|
149
|
+
f"{safe(describe_scan_failure(result.scan.error_kind, result.scan.error_message))}"
|
|
150
|
+
)
|
|
151
|
+
raise typer.Exit(EXIT_ERROR)
|
|
152
|
+
|
|
153
|
+
if output_format in ("json", "sarif"):
|
|
154
|
+
_emit_machine_readable(result, output_format, output)
|
|
155
|
+
raise typer.Exit(_fail_on_exit_code(result, fail_on))
|
|
156
|
+
|
|
157
|
+
if fix:
|
|
158
|
+
_emit_fix(result, output)
|
|
159
|
+
raise typer.Exit(_fail_on_exit_code(result, fail_on))
|
|
160
|
+
|
|
161
|
+
_render_table(result, wide)
|
|
162
|
+
raise typer.Exit(_fail_on_exit_code(result, fail_on))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _emit_fix(result: ImageAnalysis, output: str) -> None:
|
|
166
|
+
"""Emit the Dockerfile patch derived from this image's findings."""
|
|
167
|
+
plan = build_remediation_plan(result)
|
|
168
|
+
patch = render_dockerfile_patch(plan)
|
|
169
|
+
|
|
170
|
+
if output:
|
|
171
|
+
path = Path(output)
|
|
172
|
+
try:
|
|
173
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
path.write_text(patch, encoding="utf-8")
|
|
175
|
+
except OSError as e:
|
|
176
|
+
console.print(f"[red]Could not write {path}:[/red] {e}")
|
|
177
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
178
|
+
console.print(f"[green]Dockerfile patch written to {path}[/green]")
|
|
179
|
+
console.print(_fix_summary(plan))
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# soft_wrap: o destino provável é um `>` ou um copiar-e-colar, e o Rich
|
|
183
|
+
# quebraria as linhas na largura do terminal.
|
|
184
|
+
console.print(patch, soft_wrap=True, highlight=False)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _fix_summary(plan: RemediationPlan) -> str:
|
|
188
|
+
resolved = plan.resolved_count
|
|
189
|
+
parts = [f"[dim]{len(plan.actions)} layer(s), addressing {resolved} finding(s)"]
|
|
190
|
+
if plan.unresolved:
|
|
191
|
+
parts.append(f"{len(plan.unresolved)} with no published fix")
|
|
192
|
+
return " | ".join(parts) + "[/dim]"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _fail_on_exit_code(result: ImageAnalysis, fail_on: str | None) -> int:
|
|
196
|
+
"""Honours the tool-wide exit contract: 2 means "measured, and it fails".
|
|
197
|
+
|
|
198
|
+
Deliberadamente igual a `build --fail-on`: `1` continua sendo "não
|
|
199
|
+
consegui medir" e `2` "medi, e reprovou". Inverter os dois só aqui faria a
|
|
200
|
+
mesma flag significar coisas opostas em dois comandos da mesma ferramenta.
|
|
201
|
+
"""
|
|
202
|
+
if not fail_on:
|
|
203
|
+
return EXIT_OK
|
|
204
|
+
counts = {
|
|
205
|
+
"critical": result.scan.critical_count,
|
|
206
|
+
"high": result.scan.high_count,
|
|
207
|
+
"medium": result.scan.medium_count,
|
|
208
|
+
"low": result.scan.low_count,
|
|
209
|
+
}
|
|
210
|
+
cutoff = FAIL_ON_THRESHOLDS.index(fail_on.strip().lower())
|
|
211
|
+
triggering = [level for level in FAIL_ON_THRESHOLDS[: cutoff + 1] if counts[level] > 0]
|
|
212
|
+
if not triggering:
|
|
213
|
+
return EXIT_OK
|
|
214
|
+
|
|
215
|
+
# Um portão que só diz "reprovou" obriga a reabrir o relatório. Ele nomeia
|
|
216
|
+
# os CVEs que o dispararam.
|
|
217
|
+
offenders = [
|
|
218
|
+
v
|
|
219
|
+
for v in sort_by_severity(result.scan.vulnerabilities)
|
|
220
|
+
if v.severity.value.lower() in triggering
|
|
221
|
+
]
|
|
222
|
+
console.print(
|
|
223
|
+
f"\n[bold red]Gate failed (--fail-on {fail_on}):[/bold red] "
|
|
224
|
+
f"{len(offenders)} finding(s) at or above {fail_on.upper()}"
|
|
225
|
+
)
|
|
226
|
+
for v in offenders[:10]:
|
|
227
|
+
console.print(
|
|
228
|
+
f" {v.cve_id} {v.severity.value} {v.package_name} {v.installed_version}"
|
|
229
|
+
+ (f" -> {v.fixed_version}" if v.fixed_version else " (no fix)")
|
|
230
|
+
)
|
|
231
|
+
if len(offenders) > 10:
|
|
232
|
+
console.print(f" ... and {len(offenders) - 10} more")
|
|
233
|
+
return EXIT_POLICY
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _emit_machine_readable(result: ImageAnalysis, fmt: str, output: str) -> None:
|
|
237
|
+
"""Reuse the existing exporters by wrapping the single analysis in the
|
|
238
|
+
same `AnalysisResult` they already consume -- one report shape for the
|
|
239
|
+
whole tool rather than a second one that can drift."""
|
|
240
|
+
wrapped = AnalysisResult(
|
|
241
|
+
query=result.image.full_reference,
|
|
242
|
+
total_tags_scanned=1,
|
|
243
|
+
total_tags_analyzed=1,
|
|
244
|
+
baseline_met=result.production_ready,
|
|
245
|
+
recommendations=[result],
|
|
246
|
+
)
|
|
247
|
+
payload = ExporterFactory.create(fmt).export_string(wrapped)
|
|
248
|
+
|
|
249
|
+
if not output:
|
|
250
|
+
# soft_wrap: o Rich quebraria a linha na largura do terminal, e uma
|
|
251
|
+
# quebra no meio de uma string do JSON produz documento inválido.
|
|
252
|
+
console.print(payload, soft_wrap=True)
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
path = Path(output)
|
|
256
|
+
try:
|
|
257
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
path.write_text(payload, encoding="utf-8")
|
|
259
|
+
except OSError as e:
|
|
260
|
+
console.print(f"[red]Could not write {path}:[/red] {e}")
|
|
261
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
262
|
+
console.print(f"[green]Report written to {path}[/green]")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _render_table(result: ImageAnalysis, wide: bool) -> None:
|
|
266
|
+
console.print(f"\n[bold]Analysis: {safe(result.image.full_reference)}[/bold]\n")
|
|
267
|
+
|
|
268
|
+
info = Table(show_header=False, box=None, padding=(0, 2))
|
|
269
|
+
info.add_column("Key", style="bold")
|
|
270
|
+
info.add_column("Value")
|
|
271
|
+
info.add_row("Score", f"[green]{result.security_score}[/green]")
|
|
272
|
+
info.add_row("Tier", result.tier)
|
|
273
|
+
info.add_row("Critical", f"[red]{result.scan.critical_count}[/red]")
|
|
274
|
+
info.add_row("High", f"[yellow]{result.scan.high_count}[/yellow]")
|
|
275
|
+
info.add_row("Medium", str(result.scan.medium_count))
|
|
276
|
+
info.add_row("Low", str(result.scan.low_count))
|
|
277
|
+
info.add_row("Total Vulns", str(result.scan.total_count))
|
|
278
|
+
# A proporção crua ao lado da contagem: é ela que o "Remediation Score"
|
|
279
|
+
# resume num degrau, e vê-las juntas é o que impede de ler o degrau 20
|
|
280
|
+
# como "20% corrigíveis".
|
|
281
|
+
info.add_row("Fixable", f"{result.scan.fixable_count} ({_fixable_pct(result.scan)})")
|
|
282
|
+
info.add_row("Remediation Score", f"{result.remediation_score}/100")
|
|
283
|
+
info.add_row("EOL", "Yes" if result.is_eol else "No")
|
|
284
|
+
info.add_row("LTS", "Yes" if result.is_lts else "No")
|
|
285
|
+
info.add_row("Scanner", result.scan.scanner)
|
|
286
|
+
console.print(info)
|
|
287
|
+
|
|
288
|
+
if result.tag_drift_note:
|
|
289
|
+
console.print(f"\n[dim]tag history: {safe(result.tag_drift_note)}[/dim]")
|
|
290
|
+
if result.vuln_trend_note:
|
|
291
|
+
console.print(f"[dim]vuln trend: {safe(result.vuln_trend_note)}[/dim]")
|
|
292
|
+
|
|
293
|
+
if result.scan.vulnerabilities:
|
|
294
|
+
console.print("\n[bold]Vulnerabilities[/bold]\n")
|
|
295
|
+
vtable = Table()
|
|
296
|
+
# `overflow="fold"` rather than the default ellipsis: a CVE ID that
|
|
297
|
+
# somehow exceeds the reserved width wraps to a second line, still
|
|
298
|
+
# readable, instead of being cut.
|
|
299
|
+
vtable.add_column("CVE", style="cyan", min_width=_CVE_MIN_WIDTH, overflow="fold")
|
|
300
|
+
vtable.add_column("Severity")
|
|
301
|
+
vtable.add_column("CVSS", justify="right")
|
|
302
|
+
# Qual base publicou o CVSS ao lado. Sem isso, um CRITICAL com score
|
|
303
|
+
# 7.5 -- severidade do vendor, número do NVD -- lê como erro de conta
|
|
304
|
+
# da ferramenta, e quem desconfia de um número desconfia do relatório.
|
|
305
|
+
vtable.add_column("Src", style="dim")
|
|
306
|
+
# `ratio` marks these three as the flexible ones: when the terminal is
|
|
307
|
+
# too narrow, they are what shrinks.
|
|
308
|
+
vtable.add_column("Package", overflow="ellipsis", ratio=1)
|
|
309
|
+
vtable.add_column("Origin", style="magenta", overflow="ellipsis")
|
|
310
|
+
# Explorabilidade: KEV e Exploit-DB numa coluna só. Ambos os sinais
|
|
311
|
+
# já existiam no modelo e nunca chegavam à tabela -- ver
|
|
312
|
+
# `threat_label` para o motivo de dividirem uma célula, e para o
|
|
313
|
+
# motivo de "não consultado" nunca virar "No".
|
|
314
|
+
vtable.add_column("Threat", justify="center", no_wrap=True)
|
|
315
|
+
vtable.add_column("Installed", overflow="ellipsis", ratio=1)
|
|
316
|
+
vtable.add_column("Fixed", overflow="ellipsis", ratio=1)
|
|
317
|
+
vtable.add_column("Status")
|
|
318
|
+
|
|
319
|
+
sev_styles = {
|
|
320
|
+
"CRITICAL": "bold red",
|
|
321
|
+
"HIGH": "yellow",
|
|
322
|
+
"MEDIUM": "white",
|
|
323
|
+
"LOW": "dim",
|
|
324
|
+
}
|
|
325
|
+
for v in sort_by_severity(result.scan.vulnerabilities)[:30]:
|
|
326
|
+
st = sev_styles.get(v.severity.value, "")
|
|
327
|
+
status = "FIX AVAILABLE" if v.is_fixable else "NO FIX"
|
|
328
|
+
status_style = "green" if v.is_fixable else "red"
|
|
329
|
+
# Every value below originates outside this process: the CVE id
|
|
330
|
+
# and versions come from an upstream advisory, the package name
|
|
331
|
+
# from inside the image being analysed. Rich would interpret
|
|
332
|
+
# bracket markup in any of them, so a crafted package name could
|
|
333
|
+
# style a CRITICAL row to look benign.
|
|
334
|
+
threat = threat_label(v)
|
|
335
|
+
tstyle = threat_style(v)
|
|
336
|
+
vtable.add_row(
|
|
337
|
+
safe(v.cve_id),
|
|
338
|
+
f"[{st}]{v.severity.value}[/{st}]" if st else v.severity.value,
|
|
339
|
+
f"{v.cvss_score:.1f}",
|
|
340
|
+
safe(v.cvss_source) if v.cvss_source else "-",
|
|
341
|
+
safe(v.package_name),
|
|
342
|
+
origin_label(v),
|
|
343
|
+
f"[{tstyle}]{threat}[/{tstyle}]",
|
|
344
|
+
safe(v.installed_version),
|
|
345
|
+
safe(v.fixed_version) if v.fixed_version else "-",
|
|
346
|
+
f"[{status_style}]{status}[/{status_style}]",
|
|
347
|
+
)
|
|
348
|
+
_print_vulnerabilities(vtable, wide)
|
|
349
|
+
_print_origin_summary(result.scan.vulnerabilities)
|
|
350
|
+
_print_exploit_links(result.scan.vulnerabilities)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _fixable_pct(scan: ScanResult) -> str:
|
|
354
|
+
if scan.total_count == 0:
|
|
355
|
+
return "n/a"
|
|
356
|
+
return f"{scan.fixable_count / scan.total_count:.0%} of {scan.total_count}"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _print_vulnerabilities(vtable: Table, wide: bool) -> None:
|
|
360
|
+
"""Render the findings table, never at the cost of a CVE ID.
|
|
361
|
+
|
|
362
|
+
Rich only redistributes width among columns marked flexible, and only
|
|
363
|
+
when the table is told to fill the available width. Left alone, a table
|
|
364
|
+
wider than the terminal is cropped on the right instead -- which is how
|
|
365
|
+
the CVE column ended up truncated and the last column lost its border.
|
|
366
|
+
"""
|
|
367
|
+
natural_width = Measurement.get(
|
|
368
|
+
console, console.options.update(max_width=_UNBOUNDED_WIDTH), vtable
|
|
369
|
+
).maximum
|
|
370
|
+
|
|
371
|
+
if wide:
|
|
372
|
+
# Give the table exactly the width it asked for: nothing truncates.
|
|
373
|
+
Console(width=natural_width, no_color=console.no_color).print(vtable)
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
# Only fit-to-width when the terminal is too narrow. On a wide terminal
|
|
377
|
+
# the table keeps its natural layout rather than being stretched.
|
|
378
|
+
vtable.expand = natural_width > console.width
|
|
379
|
+
console.print(vtable)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _print_origin_summary(vulns: list[Vulnerability]) -> None:
|
|
383
|
+
"""Break the findings down by where the affected package lives.
|
|
384
|
+
|
|
385
|
+
Uma contagem única de "16 vulnerabilidades" não diz o que fazer com elas.
|
|
386
|
+
Saber que as 16 estão em pacotes de linguagem, e não no SO, é a diferença
|
|
387
|
+
entre `apk upgrade` (que não resolve nada) e remover o npm da imagem
|
|
388
|
+
final (que resolve todas).
|
|
389
|
+
"""
|
|
390
|
+
if not vulns:
|
|
391
|
+
return
|
|
392
|
+
counts = count_by_origin(vulns)
|
|
393
|
+
parts = [
|
|
394
|
+
f"OS packages: {counts[PackageOrigin.OS]}",
|
|
395
|
+
f"language packages: {counts[PackageOrigin.LANGUAGE]}",
|
|
396
|
+
]
|
|
397
|
+
if counts[PackageOrigin.UNKNOWN]:
|
|
398
|
+
parts.append(f"unclassified: {counts[PackageOrigin.UNKNOWN]}")
|
|
399
|
+
console.print(f"\n[bold]By origin:[/bold] {' | '.join(parts)}")
|
|
400
|
+
|
|
401
|
+
hint = npm_remediation_hint(vulns)
|
|
402
|
+
if hint:
|
|
403
|
+
console.print(f"\n[bold yellow]Remediation[/bold yellow]\n{hint}")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _print_exploit_links(vulns: list[Vulnerability]) -> None:
|
|
407
|
+
"""Onde ler o exploit, para quem quer julgar a fonte em vez de confiar
|
|
408
|
+
na coluna.
|
|
409
|
+
|
|
410
|
+
Abaixo da tabela, e não dentro dela: uma URL não cabe numa célula, e a
|
|
411
|
+
decisão que a coluna sustenta ("isto tem exploit publicado?") é anterior
|
|
412
|
+
à de abrir o link.
|
|
413
|
+
"""
|
|
414
|
+
linked = [(v, exploit_urls(v)) for v in sort_by_severity(vulns)]
|
|
415
|
+
linked = [(v, urls) for v, urls in linked if urls]
|
|
416
|
+
if not linked:
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
console.print("\n[bold]Published exploits[/bold]")
|
|
420
|
+
for vuln, urls in linked[:10]:
|
|
421
|
+
mark = " [red](verified)[/red]" if vuln.exploitdb_verified else ""
|
|
422
|
+
console.print(f" {safe(vuln.cve_id)}{mark}")
|
|
423
|
+
for url in urls[:5]:
|
|
424
|
+
# soft_wrap: uma URL quebrada na largura do terminal deixa de ser
|
|
425
|
+
# copiável, que é a única coisa que se faz com ela.
|
|
426
|
+
console.print(f" [link={url}]{url}[/link]", soft_wrap=True)
|
|
427
|
+
remaining = len(linked) - 10
|
|
428
|
+
if remaining > 0:
|
|
429
|
+
console.print(f" [dim]... and {remaining} more (see --format json)[/dim]")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Comando CLI para análise de Dockerfiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from dockerls.application.use_cases.analyze_dockerfile import (
|
|
12
|
+
AnalyzeDockerfileRequest,
|
|
13
|
+
AnalyzeDockerfileResponse,
|
|
14
|
+
AnalyzeDockerfileUseCase,
|
|
15
|
+
)
|
|
16
|
+
from dockerls.cli.dependencies import enable_console_logging
|
|
17
|
+
from dockerls.cli.rendering import render_validation_report
|
|
18
|
+
from dockerls.cli.text import safe
|
|
19
|
+
from dockerls.exit_codes import EXIT_ERROR, EXIT_OK, EXIT_POLICY
|
|
20
|
+
from dockerls.infrastructure.dockerfile_validator import DockerfileValidator, HardeningTemplates
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def analyze(
|
|
26
|
+
path: str = typer.Argument(".", help="Path to the Dockerfile or directory"),
|
|
27
|
+
validate_only: bool = typer.Option(
|
|
28
|
+
False, "--validate-only", help="Validate only, without suggesting improvements"
|
|
29
|
+
),
|
|
30
|
+
suggestions: bool = typer.Option(
|
|
31
|
+
True, "--suggestions/--no-suggestions", help="Show hardening suggestions"
|
|
32
|
+
),
|
|
33
|
+
output_format: str = typer.Option(
|
|
34
|
+
"table", "--format", "-f", help="Output format: table or json"
|
|
35
|
+
),
|
|
36
|
+
output: str | None = typer.Option(
|
|
37
|
+
None,
|
|
38
|
+
"--output",
|
|
39
|
+
"-o",
|
|
40
|
+
help="Also write the full JSON report to this file. A later "
|
|
41
|
+
"`build --compare-to-analysis <file>` reads it to say which of "
|
|
42
|
+
"these findings are still present in the image `build` scans",
|
|
43
|
+
),
|
|
44
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose logging"),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Analyze a Dockerfile for security problems."""
|
|
47
|
+
if verbose:
|
|
48
|
+
enable_console_logging()
|
|
49
|
+
|
|
50
|
+
template_provider = HardeningTemplates()
|
|
51
|
+
validator = DockerfileValidator(template_provider)
|
|
52
|
+
use_case = AnalyzeDockerfileUseCase(validator, template_provider)
|
|
53
|
+
|
|
54
|
+
request = AnalyzeDockerfileRequest(
|
|
55
|
+
dockerfile_path=path,
|
|
56
|
+
include_suggestions=suggestions,
|
|
57
|
+
validate_only=validate_only,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
response = use_case.execute(request)
|
|
61
|
+
|
|
62
|
+
if not response.success:
|
|
63
|
+
console.print(f"[red]Error:[/red] {response.error}")
|
|
64
|
+
raise typer.Exit(EXIT_ERROR)
|
|
65
|
+
|
|
66
|
+
if output:
|
|
67
|
+
try:
|
|
68
|
+
Path(output).write_text(json.dumps(response.model_dump(), indent=2), encoding="utf-8")
|
|
69
|
+
except OSError as e:
|
|
70
|
+
console.print(f"[red]Error:[/red] could not write {safe(output)}: {e}")
|
|
71
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
72
|
+
if output_format != "json":
|
|
73
|
+
console.print(f"[dim]Report written to {safe(output)}.[/dim]\n")
|
|
74
|
+
|
|
75
|
+
if output_format == "json":
|
|
76
|
+
# Via `typer.echo`, não pelo console do Rich: o consumidor é um
|
|
77
|
+
# parser. O Rich quebra a linha na largura do terminal, e uma quebra
|
|
78
|
+
# no meio de uma string do JSON produz um documento inválido -- num
|
|
79
|
+
# terminal de 80 colunas era exatamente o que saía daqui.
|
|
80
|
+
typer.echo(json.dumps(response.model_dump(), indent=2))
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
_print_table_output(response)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _print_table_output(response: AnalyzeDockerfileResponse) -> None:
|
|
87
|
+
"""Imprime resultado formatado em tabela."""
|
|
88
|
+
validation = response.validation
|
|
89
|
+
if validation is None:
|
|
90
|
+
console.print("[red]Error:[/red] validation produced no result")
|
|
91
|
+
raise typer.Exit(EXIT_ERROR)
|
|
92
|
+
|
|
93
|
+
render_validation_report(
|
|
94
|
+
console,
|
|
95
|
+
validation,
|
|
96
|
+
analysis=response.analysis,
|
|
97
|
+
suggestions=response.suggestions,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Exit code: erro de validação é violação de política (2); warnings não
|
|
101
|
+
# reprovam o build.
|
|
102
|
+
if validation.errors > 0:
|
|
103
|
+
raise typer.Exit(EXIT_POLICY)
|
|
104
|
+
raise typer.Exit(EXIT_OK)
|