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
dockerls/cli/progress.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.progress import (
|
|
7
|
+
BarColumn,
|
|
8
|
+
Progress,
|
|
9
|
+
SpinnerColumn,
|
|
10
|
+
TaskID,
|
|
11
|
+
TextColumn,
|
|
12
|
+
TimeElapsedColumn,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from types import TracebackType
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RichScanObserver:
|
|
21
|
+
"""Renders scan progress as a single self-updating line.
|
|
22
|
+
|
|
23
|
+
Exactly one `Progress` (one Rich live display) exists per run, and it
|
|
24
|
+
renders to **stderr** while results are printed to stdout. That split is
|
|
25
|
+
what makes duplication structurally impossible: the live region and the
|
|
26
|
+
results stream are different file objects, so a result can never be
|
|
27
|
+
drawn into a progress frame, and piping stdout leaves the progress
|
|
28
|
+
display on the terminal where it belongs.
|
|
29
|
+
|
|
30
|
+
Anything that writes through `sys.stdout`/`sys.stderr` during the run
|
|
31
|
+
(a stray print, a loguru console sink) is captured by Rich and re-emitted
|
|
32
|
+
above the bar rather than tearing through the live region.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, console: Console | None = None, enabled: bool = True):
|
|
36
|
+
# Default to a dedicated stderr console so the caller's stdout
|
|
37
|
+
# console is never shared with the live display.
|
|
38
|
+
self._console = console if console is not None else Console(stderr=True)
|
|
39
|
+
self._enabled = enabled
|
|
40
|
+
self._progress: Progress | None = None
|
|
41
|
+
self._task_id: TaskID | None = None
|
|
42
|
+
self._total = 0
|
|
43
|
+
self._done = 0
|
|
44
|
+
self._entered = False
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def progress(self) -> Progress | None:
|
|
48
|
+
"""The single live display, or None when disabled/not started."""
|
|
49
|
+
return self._progress
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> RichScanObserver:
|
|
52
|
+
if self._entered:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"RichScanObserver is not re-entrant: a second live display "
|
|
55
|
+
"would render a duplicate progress bar"
|
|
56
|
+
)
|
|
57
|
+
self._entered = True
|
|
58
|
+
if self._enabled:
|
|
59
|
+
self._progress = Progress(
|
|
60
|
+
SpinnerColumn(),
|
|
61
|
+
TextColumn("[progress.description]{task.description}"),
|
|
62
|
+
BarColumn(bar_width=24),
|
|
63
|
+
TimeElapsedColumn(),
|
|
64
|
+
console=self._console,
|
|
65
|
+
transient=True,
|
|
66
|
+
# Stray writes get rendered above the bar instead of
|
|
67
|
+
# corrupting the live region into a duplicate.
|
|
68
|
+
redirect_stdout=True,
|
|
69
|
+
redirect_stderr=True,
|
|
70
|
+
refresh_per_second=8,
|
|
71
|
+
)
|
|
72
|
+
self._progress.start()
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def __exit__(
|
|
76
|
+
self,
|
|
77
|
+
exc_type: type[BaseException] | None,
|
|
78
|
+
exc: BaseException | None,
|
|
79
|
+
tb: TracebackType | None,
|
|
80
|
+
) -> None:
|
|
81
|
+
if self._progress is not None:
|
|
82
|
+
self._progress.stop()
|
|
83
|
+
self._progress = None
|
|
84
|
+
self._task_id = None
|
|
85
|
+
|
|
86
|
+
def _describe(self, image_reference: str) -> str:
|
|
87
|
+
return f"Scanning {image_reference}... [{self._done + 1}/{self._total}]"
|
|
88
|
+
|
|
89
|
+
def phase(self, description: str) -> None:
|
|
90
|
+
if self._progress is None:
|
|
91
|
+
return
|
|
92
|
+
if self._task_id is None:
|
|
93
|
+
self._task_id = self._progress.add_task(description, total=None)
|
|
94
|
+
else:
|
|
95
|
+
self._progress.update(self._task_id, description=description)
|
|
96
|
+
|
|
97
|
+
def phase_result(self, title: str, facts: Sequence[tuple[str, str]]) -> None:
|
|
98
|
+
"""Print what a phase produced, above the live progress line.
|
|
99
|
+
|
|
100
|
+
Rendered as a small tree so a run reads as a sequence of accounted
|
|
101
|
+
steps rather than a spinner that may or may not be stuck:
|
|
102
|
+
|
|
103
|
+
Discovering tags
|
|
104
|
+
├─ found 100
|
|
105
|
+
├─ unique digests 84
|
|
106
|
+
└─ duplicates 16
|
|
107
|
+
|
|
108
|
+
Written through `self._console` (stderr), so it never mixes with
|
|
109
|
+
results on stdout and `dockerls recommend > out.txt` still yields a
|
|
110
|
+
clean file. When the display is disabled -- `--no-progress`, or
|
|
111
|
+
`--format json` -- nothing is emitted at all.
|
|
112
|
+
"""
|
|
113
|
+
if self._progress is None or not facts:
|
|
114
|
+
return
|
|
115
|
+
width = max(len(label) for label, _ in facts)
|
|
116
|
+
# Leading blank line: consecutive phases run together otherwise, and
|
|
117
|
+
# in a CI log they are the only structure the reader gets.
|
|
118
|
+
lines = ["", f"[bold]{title}[/bold]"]
|
|
119
|
+
for index, (label, value) in enumerate(facts):
|
|
120
|
+
branch = "└─" if index == len(facts) - 1 else "├─"
|
|
121
|
+
lines.append(f"[dim]{branch}[/dim] {label:<{width}} [cyan]{value}[/cyan]")
|
|
122
|
+
# `Progress.console.print` routes through the live display, which
|
|
123
|
+
# moves the bar down and prints above it instead of tearing it.
|
|
124
|
+
self._progress.console.print("\n".join(lines))
|
|
125
|
+
|
|
126
|
+
def start(self, total: int) -> None:
|
|
127
|
+
self._total = total
|
|
128
|
+
self._done = 0
|
|
129
|
+
if self._progress is None:
|
|
130
|
+
return
|
|
131
|
+
if self._task_id is None:
|
|
132
|
+
self._task_id = self._progress.add_task("Scanning...", total=total)
|
|
133
|
+
else:
|
|
134
|
+
self._progress.update(self._task_id, total=total, completed=0)
|
|
135
|
+
|
|
136
|
+
def scanning(self, image_reference: str) -> None:
|
|
137
|
+
if self._progress is None or self._task_id is None:
|
|
138
|
+
return
|
|
139
|
+
self._progress.update(self._task_id, description=self._describe(image_reference))
|
|
140
|
+
|
|
141
|
+
def finished(self, image_reference: str, ok: bool) -> None: # noqa: ARG002
|
|
142
|
+
self._done += 1
|
|
143
|
+
if self._progress is None or self._task_id is None:
|
|
144
|
+
return
|
|
145
|
+
self._progress.update(self._task_id, completed=self._done)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Perguntar destino e responsabilidade **antes** do build, não depois.
|
|
2
|
+
|
|
3
|
+
Três coisas precisam estar decididas antes de um `docker build` começar: para
|
|
4
|
+
onde a imagem vai, quem responde por ela, e para quem se avisa quando ela
|
|
5
|
+
tiver uma vulnerabilidade. Nenhuma delas era perguntada -- o `--push` publicava
|
|
6
|
+
a tag local como está, e `--labels` aceitava um JSON vazio sem exigir nada.
|
|
7
|
+
|
|
8
|
+
A ordem importa e é o ponto do módulo. Descobrir que o destino está errado
|
|
9
|
+
depois de validar, construir e escanear desperdiça o trabalho inteiro, e é
|
|
10
|
+
exatamente quando alguém está mais propenso a publicar em qualquer lugar só
|
|
11
|
+
para não repetir a espera. Rotular depois do build é pior ainda: a imagem já
|
|
12
|
+
existe, e rotular passa a significar reconstruir.
|
|
13
|
+
|
|
14
|
+
Nada aqui pergunta o que já foi respondido por opção de linha de comando, e
|
|
15
|
+
`--non-interactive` (assim como `--ci-mode`) troca a pergunta por uma falha
|
|
16
|
+
explícita, porque um pipeline não tem quem responda e travar esperando entrada
|
|
17
|
+
é o pior comportamento possível num runner.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
from rich.prompt import Prompt
|
|
26
|
+
|
|
27
|
+
from dockerls.domain.value_objects.build_labels import REQUIRED_FIELDS, BuildIdentity
|
|
28
|
+
from dockerls.domain.value_objects.registry_target import RegistryTarget
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
#: Exemplos por provedor, mostrados na pergunta do destino. Um formato errado
|
|
33
|
+
#: de Artifact Registry só falharia na hora do push, minutos depois.
|
|
34
|
+
DESTINATION_EXAMPLES = (
|
|
35
|
+
"Azure ACR meuregistro.azurecr.io/apps/minha-app",
|
|
36
|
+
"Google Artifact us-central1-docker.pkg.dev/meu-projeto/containers/minha-app",
|
|
37
|
+
"Google GCR gcr.io/meu-projeto/minha-app",
|
|
38
|
+
"Docker Hub minhaorg/minha-app",
|
|
39
|
+
"Registry privado registry.interna:5000/time/minha-app",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def interactive_available(*, non_interactive: bool) -> bool:
|
|
44
|
+
"""Se dá para perguntar: alguém precisa estar do outro lado do terminal."""
|
|
45
|
+
return not non_interactive and sys.stdin.isatty()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_destination(
|
|
49
|
+
destination: str | None,
|
|
50
|
+
tag: str,
|
|
51
|
+
*,
|
|
52
|
+
non_interactive: bool,
|
|
53
|
+
) -> RegistryTarget | None:
|
|
54
|
+
"""O destino de publicação, perguntado quando não veio por opção.
|
|
55
|
+
|
|
56
|
+
None significa "não publicar", que é uma resposta legítima e o padrão:
|
|
57
|
+
construir sem publicar é o fluxo mais comum.
|
|
58
|
+
"""
|
|
59
|
+
answer = (destination or "").strip()
|
|
60
|
+
if not answer:
|
|
61
|
+
if not interactive_available(non_interactive=non_interactive):
|
|
62
|
+
return None
|
|
63
|
+
console.print("\n[bold]Where is this image going?[/bold]")
|
|
64
|
+
for example in DESTINATION_EXAMPLES:
|
|
65
|
+
console.print(f" [dim]{example}[/dim]")
|
|
66
|
+
answer = Prompt.ask(
|
|
67
|
+
"Destination (empty = do not publish)",
|
|
68
|
+
default="",
|
|
69
|
+
show_default=False,
|
|
70
|
+
console=console,
|
|
71
|
+
).strip()
|
|
72
|
+
if not answer:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
target = RegistryTarget.parse(answer, tag)
|
|
76
|
+
target.validate()
|
|
77
|
+
console.print(
|
|
78
|
+
f"[dim]Destino: {target.reference} ({target.provider})\n"
|
|
79
|
+
f"Authentication: {target.login_hint}[/dim]"
|
|
80
|
+
)
|
|
81
|
+
return target
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_identity(
|
|
85
|
+
identity: BuildIdentity,
|
|
86
|
+
*,
|
|
87
|
+
non_interactive: bool,
|
|
88
|
+
) -> BuildIdentity:
|
|
89
|
+
"""Completa os rótulos obrigatórios, perguntando o que falta.
|
|
90
|
+
|
|
91
|
+
Em modo não interativo os que faltarem viram erro em vez de pergunta: um
|
|
92
|
+
pipeline não tem quem responda, e publicar sem responsável é o cenário que
|
|
93
|
+
estes campos existem para impedir.
|
|
94
|
+
"""
|
|
95
|
+
missing = identity.missing()
|
|
96
|
+
if not missing:
|
|
97
|
+
return identity
|
|
98
|
+
if not interactive_available(non_interactive=non_interactive):
|
|
99
|
+
identity.require_complete()
|
|
100
|
+
return identity
|
|
101
|
+
|
|
102
|
+
prompts = dict(REQUIRED_FIELDS)
|
|
103
|
+
answers: dict[str, str] = {}
|
|
104
|
+
console.print("\n[bold]Who answers for this image?[/bold]")
|
|
105
|
+
console.print(
|
|
106
|
+
"[dim]Becomes an OCI label on the manifest. It is what someone reads at three "
|
|
107
|
+
"in the morning to find out where the image came from and who to call.[/dim]"
|
|
108
|
+
)
|
|
109
|
+
for name in missing:
|
|
110
|
+
answers[name] = Prompt.ask(f" {prompts[name]}", console=console).strip()
|
|
111
|
+
|
|
112
|
+
completed = BuildIdentity(
|
|
113
|
+
owner=answers.get("owner", identity.owner),
|
|
114
|
+
security_contact=answers.get("security_contact", identity.security_contact),
|
|
115
|
+
source=answers.get("source", identity.source),
|
|
116
|
+
title=identity.title,
|
|
117
|
+
description=identity.description,
|
|
118
|
+
version=identity.version,
|
|
119
|
+
revision=identity.revision,
|
|
120
|
+
extra=identity.extra,
|
|
121
|
+
)
|
|
122
|
+
completed.require_complete()
|
|
123
|
+
return completed
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Shared Rich rendering for Dockerfile validation results.
|
|
2
|
+
|
|
3
|
+
`analyze-dockerfile` and `build --validate-only` report the same thing --
|
|
4
|
+
which OWASP checks a Dockerfile passed, warned on or failed -- so they render
|
|
5
|
+
it through the same code. Duplicating the table is how the two commands
|
|
6
|
+
started drifting apart, with `build` printing nothing at all.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from dockerls.domain.entities.dockerfile_analysis import ValidationStatus
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from dockerls.domain.entities.dockerfile_analysis import (
|
|
22
|
+
DockerfileAnalysis,
|
|
23
|
+
DockerfileValidationResult,
|
|
24
|
+
HardeningRule,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Valores de status renderizados, não senhas. Bandit's B105 (hardcoded
|
|
28
|
+
# password) matches on the dict *key* looking like a secret name -- "PASS"
|
|
29
|
+
# does, "WARN"/"FAIL"/"SKIP" don't -- so only that one line ever needs the
|
|
30
|
+
# suppression; the others never triggered anything to suppress.
|
|
31
|
+
_STATUS_ICONS = {
|
|
32
|
+
"PASS": "[green]PASS[/green]", # nosec B105
|
|
33
|
+
"WARN": "[yellow]WARN[/yellow]",
|
|
34
|
+
"FAIL": "[red]FAIL[/red]",
|
|
35
|
+
"SKIP": "[dim]SKIP[/dim]",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_SEVERITY_STYLES = {
|
|
39
|
+
"CRITICAL": "red bold",
|
|
40
|
+
"HIGH": "red",
|
|
41
|
+
"MEDIUM": "yellow",
|
|
42
|
+
"LOW": "dim",
|
|
43
|
+
"INFO": "dim",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_TIER_COLORS = {"A": "green", "B": "yellow", "C": "yellow", "D": "red", "F": "red"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def render_validation_report(
|
|
50
|
+
console: Console,
|
|
51
|
+
validation: DockerfileValidationResult,
|
|
52
|
+
analysis: DockerfileAnalysis | None = None,
|
|
53
|
+
suggestions: list[HardeningRule] | None = None,
|
|
54
|
+
title: str = "Dockerfile Analysis Report",
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Render the header, summary, checks table, score and suggestions.
|
|
57
|
+
|
|
58
|
+
Every section is optional except the checks table: a caller that has no
|
|
59
|
+
analysis or no suggestions simply gets fewer panels, never a `None`.
|
|
60
|
+
"""
|
|
61
|
+
_render_header(console, validation, title)
|
|
62
|
+
_render_summary(console, validation)
|
|
63
|
+
_render_checks(console, validation)
|
|
64
|
+
if analysis is not None:
|
|
65
|
+
_render_score(console, analysis)
|
|
66
|
+
_render_template_origin(console, analysis)
|
|
67
|
+
if suggestions:
|
|
68
|
+
_render_suggestions(console, suggestions)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _render_header(console: Console, validation: DockerfileValidationResult, title: str) -> None:
|
|
72
|
+
path = validation.dockerfile_path or "Dockerfile"
|
|
73
|
+
console.print(Panel(f"[bold cyan]{title}[/bold cyan]\n[dim]{path}[/dim]", expand=False))
|
|
74
|
+
console.print()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _render_summary(console: Console, validation: DockerfileValidationResult) -> None:
|
|
78
|
+
status_color = "green" if validation.errors == 0 else "red"
|
|
79
|
+
console.print(
|
|
80
|
+
f"[{status_color} bold]Summary:[/{status_color} bold] "
|
|
81
|
+
f"[green]{validation.passed} passed[/green] | "
|
|
82
|
+
f"[yellow]{validation.warnings} warnings[/yellow] | "
|
|
83
|
+
f"[red]{validation.errors} errors[/red]"
|
|
84
|
+
)
|
|
85
|
+
console.print()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _render_checks(console: Console, validation: DockerfileValidationResult) -> None:
|
|
89
|
+
if not validation.checks:
|
|
90
|
+
console.print("[dim]No validation checks were produced for this Dockerfile.[/dim]")
|
|
91
|
+
console.print()
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
table = Table(title="Validation Checks", expand=False)
|
|
95
|
+
table.add_column("Status", style="bold", width=8)
|
|
96
|
+
table.add_column("Check", style="cyan")
|
|
97
|
+
table.add_column("Message", style="white")
|
|
98
|
+
table.add_column("Severity", justify="center")
|
|
99
|
+
|
|
100
|
+
for check in validation.checks:
|
|
101
|
+
severity = check.severity.value
|
|
102
|
+
severity_style = _SEVERITY_STYLES.get(severity, "")
|
|
103
|
+
table.add_row(
|
|
104
|
+
_STATUS_ICONS.get(check.status.value, check.status.value),
|
|
105
|
+
check.check,
|
|
106
|
+
check.message,
|
|
107
|
+
f"[{severity_style}]{severity}[/{severity_style}]" if severity_style else severity,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
console.print(table)
|
|
111
|
+
console.print()
|
|
112
|
+
_render_controls(console, validation)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _render_controls(console: Console, validation: DockerfileValidationResult) -> None:
|
|
116
|
+
"""Cite the published control behind each finding that failed.
|
|
117
|
+
|
|
118
|
+
Only failures and warnings: a passing check needs no justification, and
|
|
119
|
+
printing a citation for all twelve rules would bury the two the reader
|
|
120
|
+
has to act on.
|
|
121
|
+
|
|
122
|
+
A rule with no published control says so. Leaving the line out would
|
|
123
|
+
read as "citation omitted", and the difference between "this is CIS 4.1"
|
|
124
|
+
and "this is our opinion" is exactly what the reader is entitled to.
|
|
125
|
+
"""
|
|
126
|
+
actionable = [
|
|
127
|
+
check
|
|
128
|
+
for check in validation.checks
|
|
129
|
+
if check.status in (ValidationStatus.FAIL, ValidationStatus.WARN) and check.rule_id
|
|
130
|
+
]
|
|
131
|
+
if not actionable:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
console.print("[bold]Reference controls[/bold]")
|
|
135
|
+
seen: set[str] = set()
|
|
136
|
+
for check in actionable:
|
|
137
|
+
rule_id = check.rule_id or ""
|
|
138
|
+
if rule_id in seen:
|
|
139
|
+
continue
|
|
140
|
+
seen.add(rule_id)
|
|
141
|
+
console.print(f" [cyan]{rule_id}[/cyan] {check.check}")
|
|
142
|
+
if check.rationale:
|
|
143
|
+
console.print(f" [dim]{check.rationale}[/dim]")
|
|
144
|
+
for reference in check.references:
|
|
145
|
+
console.print(f" [green]->[/green] {reference}")
|
|
146
|
+
if not check.references:
|
|
147
|
+
console.print(
|
|
148
|
+
" [dim]-> DockerLs guidance; no published control in the catalogue "
|
|
149
|
+
"covers this rule[/dim]"
|
|
150
|
+
)
|
|
151
|
+
console.print()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _render_score(console: Console, analysis: DockerfileAnalysis) -> None:
|
|
155
|
+
tier_color = _TIER_COLORS.get(analysis.security_tier, "white")
|
|
156
|
+
ready = "[green]Yes[/green]" if analysis.is_production_ready else "[red]No[/red]"
|
|
157
|
+
console.print(
|
|
158
|
+
Panel(
|
|
159
|
+
f"[bold]Security Score: {analysis.security_score}/100[/bold]\n"
|
|
160
|
+
f"Tier: [{tier_color} bold]{analysis.security_tier}[/{tier_color} bold]\n"
|
|
161
|
+
f"Production Ready: {ready}",
|
|
162
|
+
expand=False,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
console.print()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _render_template_origin(console: Console, analysis: DockerfileAnalysis) -> None:
|
|
169
|
+
"""This Dockerfile against the hardened template it most resembles.
|
|
170
|
+
|
|
171
|
+
Purely informative -- it does not touch the score or the tier, which
|
|
172
|
+
keep measuring the Dockerfile as it is, not where it came from. Silent
|
|
173
|
+
when nothing matched closely enough: a generic Dockerfile is not "an
|
|
174
|
+
edited template with a 0% match", it is just not one.
|
|
175
|
+
"""
|
|
176
|
+
origin = analysis.template_origin
|
|
177
|
+
if origin is None:
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
console.print(
|
|
181
|
+
Panel(
|
|
182
|
+
f"[bold]Derived from template:[/bold] [cyan]{origin.template_name}[/cyan]\n"
|
|
183
|
+
f"Similarity: {origin.similarity:.0%}\n"
|
|
184
|
+
"[dim]Diff against the hardened template shown below.[/dim]",
|
|
185
|
+
title="Template origin",
|
|
186
|
+
expand=False,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
for line in origin.diff[:60]:
|
|
190
|
+
style = ""
|
|
191
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
192
|
+
style = "green"
|
|
193
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
194
|
+
style = "red"
|
|
195
|
+
elif line.startswith("@@"):
|
|
196
|
+
style = "cyan"
|
|
197
|
+
console.print(f"[{style}]{line}[/{style}]" if style else line, soft_wrap=True)
|
|
198
|
+
if len(origin.diff) > 60:
|
|
199
|
+
console.print(f"[dim]... {len(origin.diff) - 60} more diff line(s) omitted[/dim]")
|
|
200
|
+
console.print()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _render_suggestions(console: Console, suggestions: list[HardeningRule]) -> None:
|
|
204
|
+
console.print(Panel("[bold yellow]Recommendations[/bold yellow]", expand=False))
|
|
205
|
+
|
|
206
|
+
for i, suggestion in enumerate(suggestions, 1):
|
|
207
|
+
priority_style = _SEVERITY_STYLES.get(suggestion.priority.value, "")
|
|
208
|
+
console.print(f"\n[{priority_style}]#{i}. {suggestion.title}[/{priority_style}]")
|
|
209
|
+
console.print(f" [dim]{suggestion.description}[/dim]")
|
|
210
|
+
console.print(f" Current: [yellow]{suggestion.current_state}[/yellow]")
|
|
211
|
+
console.print(f" Fix: [green]{suggestion.suggested_fix}[/green]")
|
|
212
|
+
console.print(f" [italic]Reason: {suggestion.reason}[/italic]")
|
|
213
|
+
|
|
214
|
+
console.print()
|
dockerls/cli/runtime.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""As Settings do processo e o logging, sem o contêiner de dependências.
|
|
2
|
+
|
|
3
|
+
Isto morava em `cli/dependencies.py`, e o problema não era o código: era o
|
|
4
|
+
módulo. `dependencies.py` importa a aplicação inteira -- pipeline de scan,
|
|
5
|
+
clientes HTTP, pydantic-settings, SQLAlchemy -- e o callback que roda antes
|
|
6
|
+
de *todo* subcomando só precisava daqui. `dockerls version`, que imprime
|
|
7
|
+
uma string, pagava ~250ms para configurar um sink de log.
|
|
8
|
+
|
|
9
|
+
Separado, o callback importa este módulo (Settings + logging, e nada mais)
|
|
10
|
+
e os comandos que de fato trabalham seguem importando `dependencies`, que
|
|
11
|
+
reexporta estes nomes para não mudar nada do lado de quem chama.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from dockerls.infrastructure.config.settings import Settings
|
|
20
|
+
from dockerls.infrastructure.logging.setup import setup_logging
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# Populated by _settings() on first use; exposed so commands can tell the
|
|
26
|
+
# user exactly which file the run's diagnostics landed in.
|
|
27
|
+
_LOG_FILE: Path | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@lru_cache(maxsize=1)
|
|
31
|
+
def _settings() -> Settings:
|
|
32
|
+
global _LOG_FILE
|
|
33
|
+
s = Settings()
|
|
34
|
+
s.ensure_dirs()
|
|
35
|
+
_LOG_FILE = setup_logging(s.log_level, log_dir=s.log_dir)
|
|
36
|
+
return s
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def current_log_file() -> Path | None:
|
|
40
|
+
_settings()
|
|
41
|
+
return _LOG_FILE
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def configure_logging() -> None:
|
|
45
|
+
"""Detach loguru's default stderr sink before any command runs.
|
|
46
|
+
|
|
47
|
+
Until a sink is configured, loguru logs everything from DEBUG up to
|
|
48
|
+
stderr. Commands that never touched Settings -- `build` was one --
|
|
49
|
+
inherited that default and leaked INFO lines into the terminal.
|
|
50
|
+
"""
|
|
51
|
+
_settings()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def enable_console_logging() -> None:
|
|
55
|
+
"""Re-attach the stderr sink (``--verbose``) on top of the file sink.
|
|
56
|
+
|
|
57
|
+
The stderr sink runs at the configured `log_level` here (INFO by
|
|
58
|
+
default, DEBUG via DOCKERLS_LOG_LEVEL) rather than the WARNING floor
|
|
59
|
+
that applies without ``--verbose``.
|
|
60
|
+
"""
|
|
61
|
+
s = _settings()
|
|
62
|
+
global _LOG_FILE
|
|
63
|
+
_LOG_FILE = setup_logging(
|
|
64
|
+
s.log_level, log_dir=s.log_dir, console=True, console_level=s.log_level
|
|
65
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""A causa de um scan que não completou, em uma linha legível.
|
|
2
|
+
|
|
3
|
+
O terminal mostrava o stderr cru do scanner. Para uma tag inexistente o
|
|
4
|
+
Trivy responde com um bloco de várias linhas que menciona o socket do
|
|
5
|
+
Docker -- um daemon que este modo de scan nem usa --, e o leitor tinha
|
|
6
|
+
que garimpar nesse texto a única informação que importa: a tag não existe
|
|
7
|
+
no registry.
|
|
8
|
+
|
|
9
|
+
A causa já vem classificada em `ScanErrorKind` (ver
|
|
10
|
+
`integrations/scan_errors.py`). Aqui ela vira a frase que o usuário lê. O
|
|
11
|
+
texto bruto continua indo para o arquivo de log e para `--format json`
|
|
12
|
+
via `ScanResult.error_message`: resumir na tela não é esconder, é parar
|
|
13
|
+
de despejar num lugar onde ninguém consegue ler.
|
|
14
|
+
|
|
15
|
+
Uma função só, usada por `analyze`, `compare`, `advisor` e `alternatives`,
|
|
16
|
+
porque quatro cópias da mesma frase divergem na primeira vez que uma
|
|
17
|
+
delas for corrigida.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dockerls.domain.entities.scan_result import ScanErrorKind
|
|
23
|
+
|
|
24
|
+
#: O que cada causa classificada significa, nos termos de quem lê. São
|
|
25
|
+
#: afirmações sobre o que aconteceu, não instruções -- a ação sugerida é
|
|
26
|
+
#: assunto de quem renderiza (`recommend` tem a sua própria tabela de
|
|
27
|
+
#: dicas, que responde "o que fazer" em vez de "o que houve").
|
|
28
|
+
_CAUSES: dict[ScanErrorKind, str] = {
|
|
29
|
+
ScanErrorKind.NOT_FOUND: "tag not found on the registry",
|
|
30
|
+
ScanErrorKind.AUTH_REQUIRED: "the registry requires credentials",
|
|
31
|
+
ScanErrorKind.RATE_LIMITED: "rate limited by the registry",
|
|
32
|
+
ScanErrorKind.DB_INIT_FAILED: "the vulnerability database could not be prepared",
|
|
33
|
+
ScanErrorKind.TIMEOUT: "the scan exceeded its timeout",
|
|
34
|
+
ScanErrorKind.SCANNER_MISSING: "no scanner executable was found",
|
|
35
|
+
ScanErrorKind.INVALID_OUTPUT: "the scanner produced output that could not be parsed",
|
|
36
|
+
ScanErrorKind.BLOCKED_BY_POLICY: "the network policy refused this host",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#: Quanto do stderr cru sobrevive quando não há causa classificada. O
|
|
40
|
+
#: texto inteiro está no log e no JSON; aqui só cabe o suficiente para
|
|
41
|
+
#: distinguir uma falha da outra.
|
|
42
|
+
REASON_MAX_LEN = 90
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def short_reason(reason: str) -> str:
|
|
46
|
+
"""Colapsa um stderr de várias linhas em uma linha legível."""
|
|
47
|
+
collapsed = " ".join(reason.split())
|
|
48
|
+
if len(collapsed) <= REASON_MAX_LEN:
|
|
49
|
+
return collapsed
|
|
50
|
+
return collapsed[: REASON_MAX_LEN - 3] + "..."
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def describe_scan_failure(kind: ScanErrorKind | str, message: str = "") -> str:
|
|
54
|
+
"""`KIND -- causa em português claro`, sem o dump do scanner.
|
|
55
|
+
|
|
56
|
+
Sem classificação (UNKNOWN, ou um scanner cuja saída ninguém mapeou
|
|
57
|
+
ainda) sobra o começo do stderr, colapsado numa linha: é pior que uma
|
|
58
|
+
frase, e ainda assim melhor que nada.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
classified = ScanErrorKind(kind)
|
|
62
|
+
except ValueError:
|
|
63
|
+
# Um `kind` que não é um membro do enum vem de dado externo
|
|
64
|
+
# (cache antigo, JSON de outra versão). Não é motivo para o
|
|
65
|
+
# comando morrer: o rótulo é mostrado como veio.
|
|
66
|
+
return f"{kind} -- {short_reason(message) or 'no details'}"
|
|
67
|
+
|
|
68
|
+
cause = _CAUSES.get(classified)
|
|
69
|
+
if cause:
|
|
70
|
+
return f"{classified.value} -- {cause}"
|
|
71
|
+
return f"{classified.value} -- {short_reason(message) or 'no details'}"
|
dockerls/cli/text.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Render third-party text as text, never as formatting instructions.
|
|
2
|
+
|
|
3
|
+
Rich interprets square-bracket markup in everything it prints, and a large
|
|
4
|
+
share of what this tool prints did not originate here. CVE titles come from
|
|
5
|
+
upstream advisories, package names and file paths come from inside the image
|
|
6
|
+
under analysis, error strings come from a scanner's stderr, and catalogue
|
|
7
|
+
metadata comes from a repository on the internet. All of it is data.
|
|
8
|
+
|
|
9
|
+
Left unescaped, a description reading
|
|
10
|
+
|
|
11
|
+
[red]FIXED - no action needed[/red]
|
|
12
|
+
|
|
13
|
+
is *interpreted*: the brackets vanish, the words are styled, and the report
|
|
14
|
+
now contains a sentence that looks like the tool's own annotation. Whoever
|
|
15
|
+
controls an advisory or a package's metadata controls how a finding is
|
|
16
|
+
presented -- they can style a CRITICAL to look benign, or make text
|
|
17
|
+
disappear. That is the one place in this codebase where untrusted input
|
|
18
|
+
becomes presentation logic, and it closes here.
|
|
19
|
+
|
|
20
|
+
`safe()` is applied at the point of interpolation rather than by turning
|
|
21
|
+
markup off wholesale, because the styling this tool applies to its *own*
|
|
22
|
+
strings is what makes the output readable. The rule is simple: our literals
|
|
23
|
+
may carry markup; anything that arrived from outside goes through `safe`.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from rich.markup import escape
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def safe(value: object) -> str:
|
|
32
|
+
"""Escape Rich markup in `value` so it renders exactly as it reads.
|
|
33
|
+
|
|
34
|
+
Accepts any object and stringifies it: the call sites interpolate CVE
|
|
35
|
+
ids, package names, paths, counts and enum values, and requiring the
|
|
36
|
+
caller to remember which of those are already strings is how one gets
|
|
37
|
+
missed.
|
|
38
|
+
"""
|
|
39
|
+
return escape("" if value is None else str(value))
|