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,761 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from dockerls.application.services.source_registry import UnknownSourceError
|
|
16
|
+
from dockerls.cli.dependencies import (
|
|
17
|
+
build_recommend_use_case,
|
|
18
|
+
enable_console_logging,
|
|
19
|
+
resolve_tag_limit,
|
|
20
|
+
)
|
|
21
|
+
from dockerls.cli.image_names import display_reference, reject_tagged_reference
|
|
22
|
+
from dockerls.cli.options import OutputFormat, parse_output_format
|
|
23
|
+
from dockerls.cli.progress import RichScanObserver
|
|
24
|
+
from dockerls.cli.scan_failure import short_reason
|
|
25
|
+
from dockerls.cli.text import safe
|
|
26
|
+
from dockerls.cli.validators import check_limit, check_threshold, check_workers
|
|
27
|
+
from dockerls.domain.value_objects.confidence import Confidence
|
|
28
|
+
from dockerls.domain.value_objects.security_tier import SecurityTier, Tier
|
|
29
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
30
|
+
from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
|
|
31
|
+
from dockerls.infrastructure.evidence import slugify_reference
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
|
|
36
|
+
from dockerls.application.dto.analysis import (
|
|
37
|
+
AnalysisResult,
|
|
38
|
+
DimensionReport,
|
|
39
|
+
ImageAnalysis,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
console = Console()
|
|
43
|
+
|
|
44
|
+
# Exit codes, in order of severity:
|
|
45
|
+
# 0 = an image meeting the baseline (Critical=0, High=0, Medium<=max) was found
|
|
46
|
+
# 1 = a hard error occurred (nothing could be scanned, or --fail-on was violated)
|
|
47
|
+
# 2 = no baseline image, but fallback alternatives were found
|
|
48
|
+
# 3 = nothing usable was found at all
|
|
49
|
+
# 0 e 1 vêm do contrato compartilhado; 2 e 3 são próprios de `recommend`,
|
|
50
|
+
# que escolhe entre candidatos em vez de avaliar um artefato do usuário.
|
|
51
|
+
EXIT_BASELINE_MET = EXIT_OK
|
|
52
|
+
EXIT_ERROR_CODE = EXIT_ERROR
|
|
53
|
+
EXIT_ALTERNATIVES_FOUND = 2
|
|
54
|
+
EXIT_NONE_FOUND = 3
|
|
55
|
+
|
|
56
|
+
DISPUTED_SCORE_LABEL = "[yellow]!disputed[/yellow]"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FailOn(StrEnum):
|
|
60
|
+
NONE = "none"
|
|
61
|
+
CRITICAL = "critical"
|
|
62
|
+
HIGH = "high"
|
|
63
|
+
MEDIUM = "medium"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_FAIL_ON_COUNT: dict[FailOn, Callable[[ImageAnalysis], int]] = {
|
|
67
|
+
FailOn.CRITICAL: lambda a: a.scan.critical_count,
|
|
68
|
+
FailOn.HIGH: lambda a: a.scan.critical_count + a.scan.high_count,
|
|
69
|
+
FailOn.MEDIUM: lambda a: a.scan.critical_count + a.scan.high_count + a.scan.medium_count,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def recommend(
|
|
74
|
+
image: str = typer.Argument(
|
|
75
|
+
help=(
|
|
76
|
+
"Docker image name only, without a tag (e.g. 'node', not 'node:18'). "
|
|
77
|
+
"Use 'analyze' or 'advisor' for a specific tag."
|
|
78
|
+
)
|
|
79
|
+
),
|
|
80
|
+
max_critical: int | None = typer.Option(
|
|
81
|
+
None, "--max-critical", help="Max critical vulns allowed [config: max_critical, default 0]"
|
|
82
|
+
),
|
|
83
|
+
max_high: int | None = typer.Option(
|
|
84
|
+
None, "--max-high", help="Max high vulns allowed [config: max_high, default 0]"
|
|
85
|
+
),
|
|
86
|
+
max_medium: int | None = typer.Option(
|
|
87
|
+
None, "--max-medium", help="Max medium vulns allowed [config: max_medium, default 5]"
|
|
88
|
+
),
|
|
89
|
+
limit: int | None = typer.Option(
|
|
90
|
+
None, "--limit", "-l", help="Max tags to discover [config: max_tags, default 100]"
|
|
91
|
+
),
|
|
92
|
+
budget: int | None = typer.Option(
|
|
93
|
+
None,
|
|
94
|
+
"--budget",
|
|
95
|
+
"-b",
|
|
96
|
+
help="Max tags to actually scan; 0 scans every tag found [config: scan_budget, default 25]",
|
|
97
|
+
),
|
|
98
|
+
workers: int | None = typer.Option(
|
|
99
|
+
None,
|
|
100
|
+
"--workers",
|
|
101
|
+
"-w",
|
|
102
|
+
help="Concurrent scanner processes; 0 sizes it to this machine [config: workers]",
|
|
103
|
+
),
|
|
104
|
+
fail_on: FailOn = typer.Option(
|
|
105
|
+
FailOn.NONE, "--fail-on", help="Exit non-zero if the top result has vulns at/above severity"
|
|
106
|
+
),
|
|
107
|
+
output_format: str = typer.Option(
|
|
108
|
+
OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
|
|
109
|
+
),
|
|
110
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
|
|
111
|
+
no_progress: bool = typer.Option(False, "--no-progress", help="Disable the progress display"),
|
|
112
|
+
no_cross_validate: bool = typer.Option(
|
|
113
|
+
False, "--no-cross-validate", help="Skip second-scanner validation of top candidates"
|
|
114
|
+
),
|
|
115
|
+
no_hub_check: bool = typer.Option(
|
|
116
|
+
False, "--no-hub-check", help="Skip registry tag existence verification"
|
|
117
|
+
),
|
|
118
|
+
no_hardened: bool = typer.Option(
|
|
119
|
+
False, "--no-hardened", help="Search Docker Hub only (skip Chainguard/Distroless)"
|
|
120
|
+
),
|
|
121
|
+
source: list[str] = typer.Option(
|
|
122
|
+
[],
|
|
123
|
+
"--source",
|
|
124
|
+
"-s",
|
|
125
|
+
help=(
|
|
126
|
+
"Image source to search; repeatable. "
|
|
127
|
+
"One of: dockerhub, chainguard, distroless, dhi, private (if configured), all"
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
all_sources: bool = typer.Option(
|
|
131
|
+
False, "--all-sources", help="Search every configured source, including opt-in ones"
|
|
132
|
+
),
|
|
133
|
+
no_cache: bool = typer.Option(
|
|
134
|
+
False, "--no-cache", help="Ignore cached analyses and re-scan every candidate"
|
|
135
|
+
),
|
|
136
|
+
verbose: bool = typer.Option(
|
|
137
|
+
False, "--verbose", "-v", help="Also print logs to stderr (they always go to the log file)"
|
|
138
|
+
),
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Recommend the most secure Docker image tags."""
|
|
141
|
+
if no_color:
|
|
142
|
+
console.no_color = True
|
|
143
|
+
if verbose:
|
|
144
|
+
enable_console_logging()
|
|
145
|
+
fmt = parse_output_format(output_format)
|
|
146
|
+
|
|
147
|
+
# Validated here, before any dependency is built: an out-of-range value
|
|
148
|
+
# must produce a readable CLI error rather than a traceback from deep
|
|
149
|
+
# inside the use case (or, for `--workers 0`, a semaphore nobody can
|
|
150
|
+
# acquire). `None` means "not given", so the configured default applies.
|
|
151
|
+
if max_critical is not None:
|
|
152
|
+
max_critical = check_threshold(max_critical, "max_critical")
|
|
153
|
+
if max_high is not None:
|
|
154
|
+
max_high = check_threshold(max_high, "max_high")
|
|
155
|
+
if max_medium is not None:
|
|
156
|
+
max_medium = check_threshold(max_medium, "max_medium")
|
|
157
|
+
if limit is not None:
|
|
158
|
+
limit = check_limit(limit)
|
|
159
|
+
# `0` é um valor legítimo aqui -- significa "meça todas" -- e por isso
|
|
160
|
+
# não passa pelo validador de limite, que recusa zero.
|
|
161
|
+
if budget is not None and budget < 0:
|
|
162
|
+
raise typer.BadParameter("--budget cannot be negative")
|
|
163
|
+
# `0` means "size it to this machine", so it is passed through rather
|
|
164
|
+
# than validated: the resolver, not the flag, decides what it becomes.
|
|
165
|
+
if workers:
|
|
166
|
+
workers = check_workers(workers)
|
|
167
|
+
|
|
168
|
+
error = reject_tagged_reference(image, "recommend")
|
|
169
|
+
if error:
|
|
170
|
+
console.print(f"[red]{error}[/red]")
|
|
171
|
+
raise typer.Exit(EXIT_ERROR_CODE)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
asyncio.run(
|
|
175
|
+
_recommend(
|
|
176
|
+
image,
|
|
177
|
+
max_critical,
|
|
178
|
+
max_high,
|
|
179
|
+
max_medium,
|
|
180
|
+
limit,
|
|
181
|
+
budget,
|
|
182
|
+
workers,
|
|
183
|
+
fail_on,
|
|
184
|
+
fmt,
|
|
185
|
+
show_progress=not no_progress and fmt != OutputFormat.JSON,
|
|
186
|
+
cross_validate=not no_cross_validate,
|
|
187
|
+
verify_hub_tags=not no_hub_check,
|
|
188
|
+
include_hardened=not no_hardened,
|
|
189
|
+
use_cache=not no_cache,
|
|
190
|
+
sources=list(source) or None,
|
|
191
|
+
all_sources=all_sources,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
except UnknownSourceError as e:
|
|
195
|
+
# Its own arm: a mistyped source names a fixable mistake, and the
|
|
196
|
+
# exception already carries the valid choices.
|
|
197
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
198
|
+
raise typer.Exit(EXIT_ERROR_CODE) from e
|
|
199
|
+
except ValueError as e:
|
|
200
|
+
# Bad thresholds are user error, not a crash: show the message, not
|
|
201
|
+
# a stack trace (pretty_exceptions_enable is off app-wide).
|
|
202
|
+
console.print(f"[red]Invalid configuration:[/red] {e}")
|
|
203
|
+
raise typer.Exit(EXIT_ERROR_CODE) from e
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def _recommend(
|
|
207
|
+
image: str,
|
|
208
|
+
max_critical: int | None,
|
|
209
|
+
max_high: int | None,
|
|
210
|
+
max_medium: int | None,
|
|
211
|
+
limit: int | None,
|
|
212
|
+
budget: int | None,
|
|
213
|
+
workers: int | None,
|
|
214
|
+
fail_on: FailOn,
|
|
215
|
+
output_format: OutputFormat,
|
|
216
|
+
show_progress: bool = True,
|
|
217
|
+
cross_validate: bool = True,
|
|
218
|
+
verify_hub_tags: bool = True,
|
|
219
|
+
include_hardened: bool = True,
|
|
220
|
+
use_cache: bool = True,
|
|
221
|
+
sources: list[str] | None = None,
|
|
222
|
+
all_sources: bool = False,
|
|
223
|
+
) -> None:
|
|
224
|
+
# The observer builds its own stderr console; `console` (stdout) is left
|
|
225
|
+
# exclusively for results so the two streams cannot interleave.
|
|
226
|
+
with RichScanObserver(enabled=show_progress) as observer:
|
|
227
|
+
use_case = await build_recommend_use_case(
|
|
228
|
+
max_critical=max_critical,
|
|
229
|
+
max_high=max_high,
|
|
230
|
+
max_medium=max_medium,
|
|
231
|
+
workers=workers,
|
|
232
|
+
observer=observer,
|
|
233
|
+
cross_validate=cross_validate,
|
|
234
|
+
verify_hub_tags=verify_hub_tags,
|
|
235
|
+
include_hardened=include_hardened,
|
|
236
|
+
use_cache=use_cache,
|
|
237
|
+
sources=sources,
|
|
238
|
+
all_sources=all_sources,
|
|
239
|
+
scan_budget=budget,
|
|
240
|
+
)
|
|
241
|
+
result = await use_case.execute(image, limit=resolve_tag_limit(limit))
|
|
242
|
+
|
|
243
|
+
if output_format == OutputFormat.JSON:
|
|
244
|
+
console.print(json.dumps(result.model_dump(), indent=2, default=str), soft_wrap=True)
|
|
245
|
+
raise typer.Exit(_exit_code(result, fail_on))
|
|
246
|
+
|
|
247
|
+
_print_summary(result)
|
|
248
|
+
|
|
249
|
+
if result.baseline_met and result.recommendations:
|
|
250
|
+
console.print(Panel("[bold green]Recommended Images[/bold green]", expand=False))
|
|
251
|
+
_print_table(result.recommendations)
|
|
252
|
+
_print_why(result.recommendations)
|
|
253
|
+
_print_details(result.recommendations)
|
|
254
|
+
_print_divergences(result.recommendations)
|
|
255
|
+
elif result.alternatives:
|
|
256
|
+
console.print(Panel(_baseline_miss_message(result), expand=False))
|
|
257
|
+
_print_table(result.alternatives)
|
|
258
|
+
_print_why(result.alternatives)
|
|
259
|
+
_print_details(result.alternatives)
|
|
260
|
+
_print_divergences(result.alternatives)
|
|
261
|
+
elif _nothing_could_be_measured(result):
|
|
262
|
+
console.print(_measurement_failure_message(result))
|
|
263
|
+
else:
|
|
264
|
+
console.print("[red]No suitable images found.[/red]")
|
|
265
|
+
console.print(f"[dim]{_baseline_line(result)}[/dim]")
|
|
266
|
+
|
|
267
|
+
_print_tier_warnings(result.recommendations or result.alternatives)
|
|
268
|
+
_print_unverified(result)
|
|
269
|
+
_print_deferred(result)
|
|
270
|
+
|
|
271
|
+
if result.evidence_manifest:
|
|
272
|
+
console.print(f"\n[dim]Evidence manifest: {result.evidence_manifest}[/dim]")
|
|
273
|
+
|
|
274
|
+
raise typer.Exit(_exit_code(result, fail_on))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _baseline_line(result: AnalysisResult) -> str:
|
|
278
|
+
if result.baseline is None:
|
|
279
|
+
return ""
|
|
280
|
+
return f"Baseline: {result.baseline.describe()}."
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _baseline_miss_message(result: AnalysisResult) -> str:
|
|
284
|
+
"""Name the exact threshold that was not met, so "no match" is a fact
|
|
285
|
+
the reader can check rather than an opaque verdict.
|
|
286
|
+
|
|
287
|
+
O ranking abaixo do alvo é apresentado como tal, e com todas as letras:
|
|
288
|
+
são as melhores candidatas encontradas, nenhuma delas aprovada. Antes o
|
|
289
|
+
caminho alternativo exigia `critical_count == 0` -- o mesmo critério que o
|
|
290
|
+
baseline acabara de rejeitar --, então quando toda tag tinha um CRITICAL o
|
|
291
|
+
usuário recebia "No suitable images found" e nada mais, depois de esperar
|
|
292
|
+
por uma centena de scans.
|
|
293
|
+
"""
|
|
294
|
+
baseline = _baseline_line(result)
|
|
295
|
+
detail = f"{baseline}\n" if baseline else ""
|
|
296
|
+
return (
|
|
297
|
+
f"[bold yellow]No image meets the baseline.[/bold yellow]\n"
|
|
298
|
+
f"[yellow]{detail}"
|
|
299
|
+
f"Showing the best candidates found -- all of them below target.[/yellow]"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _print_summary(result: AnalysisResult) -> None:
|
|
304
|
+
"""One-line account of the run, so a clean table can never hide the
|
|
305
|
+
fact that half the candidates failed to scan."""
|
|
306
|
+
analyzed = result.total_tags_analyzed
|
|
307
|
+
total = result.total_tags_scanned
|
|
308
|
+
skipped = result.unverified_count
|
|
309
|
+
|
|
310
|
+
parts = [f"[green]OK {analyzed}/{total} analyzed[/green]"]
|
|
311
|
+
if skipped:
|
|
312
|
+
parts.append(f"[yellow]X {skipped} skipped (technical error)[/yellow]")
|
|
313
|
+
if result.deferred_count:
|
|
314
|
+
# Dito no mesmo lugar que os scans que falharam, e com palavras
|
|
315
|
+
# diferentes: um scan que falhou é uma medição que não deu certo,
|
|
316
|
+
# uma tag adiada é uma medição que nem foi tentada. As duas são
|
|
317
|
+
# ausência de medição, e nenhuma é um veredito sobre a imagem.
|
|
318
|
+
parts.append(f"[cyan]- {result.deferred_count} not measured[/cyan]")
|
|
319
|
+
if result.sources_searched:
|
|
320
|
+
parts.append(f"[magenta]sources: {', '.join(result.sources_searched)}[/magenta]")
|
|
321
|
+
console.print(" | ".join(parts))
|
|
322
|
+
|
|
323
|
+
work = _work_line(result)
|
|
324
|
+
if work:
|
|
325
|
+
# Second line rather than more fields on the first: the first line
|
|
326
|
+
# answers "what did it find", this one answers "what did it do",
|
|
327
|
+
# and cramming both together made neither readable at 80 columns.
|
|
328
|
+
console.print(work)
|
|
329
|
+
if result.log_file:
|
|
330
|
+
# Its own line: a wrapped path is a path the user cannot copy.
|
|
331
|
+
console.print(f"[dim]log: {result.log_file}[/dim]", soft_wrap=True)
|
|
332
|
+
console.print()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _work_line(result: AnalysisResult) -> str:
|
|
336
|
+
"""Account for the work the run did, not just what it found.
|
|
337
|
+
|
|
338
|
+
"Analyzed 84/100" says nothing about whether those 84 cost 84 scans or
|
|
339
|
+
5, which is the difference between four minutes and twenty seconds. The
|
|
340
|
+
numbers were already being computed and thrown away.
|
|
341
|
+
"""
|
|
342
|
+
m = result.metrics
|
|
343
|
+
if not m.tags_discovered:
|
|
344
|
+
return ""
|
|
345
|
+
|
|
346
|
+
parts = [f"scans: {m.scans_performed}"]
|
|
347
|
+
if m.cache_hits:
|
|
348
|
+
parts.append(f"cache: {m.cache_hits} hit ({m.cache_hit_rate:.0%})")
|
|
349
|
+
if m.duplicates_collapsed:
|
|
350
|
+
parts.append(f"deduped: {m.duplicates_collapsed}")
|
|
351
|
+
if m.cross_validations:
|
|
352
|
+
parts.append(f"cross-validated: {m.cross_validations}")
|
|
353
|
+
if m.workers:
|
|
354
|
+
parts.append(f"workers: {m.workers}")
|
|
355
|
+
return f"[dim]{' | '.join(parts)}[/dim]"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _hub_status(analysis: ImageAnalysis) -> str:
|
|
359
|
+
if analysis.hub_tag_verified is True:
|
|
360
|
+
return "[green]OK[/green]"
|
|
361
|
+
if analysis.hub_tag_verified is False:
|
|
362
|
+
return "[red]missing[/red]"
|
|
363
|
+
return "[dim]n/a[/dim]"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _print_table(analyses: list[ImageAnalysis]) -> None:
|
|
367
|
+
# Kept deliberately narrow so it survives an 80-column terminal without
|
|
368
|
+
# ellipsizing the image reference -- the one cell the reader must be
|
|
369
|
+
# able to copy verbatim. Severity counts collapse into a single
|
|
370
|
+
# Critical/High/Medium cell, and full Hub URLs are listed below.
|
|
371
|
+
table = Table()
|
|
372
|
+
table.add_column("#", justify="right", style="dim")
|
|
373
|
+
table.add_column("Image", style="cyan bold", overflow="fold")
|
|
374
|
+
table.add_column("Source", style="magenta")
|
|
375
|
+
table.add_column("Score", justify="right", style="green", no_wrap=True)
|
|
376
|
+
table.add_column("Tier", justify="center")
|
|
377
|
+
table.add_column("C/H/M", justify="center", no_wrap=True)
|
|
378
|
+
table.add_column("Hard", justify="right", no_wrap=True)
|
|
379
|
+
table.add_column("Surf", justify="right", no_wrap=True)
|
|
380
|
+
table.add_column("Conf", justify="center", no_wrap=True)
|
|
381
|
+
table.add_column("Fix", justify="right", style="green")
|
|
382
|
+
table.add_column("Rem", justify="right")
|
|
383
|
+
table.add_column("Tag", justify="center")
|
|
384
|
+
table.add_column("Threat", justify="center", no_wrap=True)
|
|
385
|
+
|
|
386
|
+
styles = {
|
|
387
|
+
"A": "bold green",
|
|
388
|
+
"B": "bold blue",
|
|
389
|
+
"C": "bold yellow",
|
|
390
|
+
"D": "bold red",
|
|
391
|
+
"E": "bold red",
|
|
392
|
+
"F": "bold white on red",
|
|
393
|
+
}
|
|
394
|
+
for i, a in enumerate(analyses, 1):
|
|
395
|
+
ts = styles.get(a.tier, "")
|
|
396
|
+
# A score two scanners disagree about is not shown as a number:
|
|
397
|
+
# displaying it would imply a confidence the data does not support.
|
|
398
|
+
score = DISPUTED_SCORE_LABEL if a.scan_divergence else str(a.security_score)
|
|
399
|
+
crit_style = "red" if a.scan.critical_count else "dim"
|
|
400
|
+
counts = (
|
|
401
|
+
f"[{crit_style}]{a.scan.critical_count}[/{crit_style}]/"
|
|
402
|
+
f"[yellow]{a.scan.high_count}[/yellow]/{a.scan.medium_count}"
|
|
403
|
+
)
|
|
404
|
+
table.add_row(
|
|
405
|
+
str(i),
|
|
406
|
+
safe(display_reference(a.image.name, a.image.tag)),
|
|
407
|
+
safe(a.image.source),
|
|
408
|
+
score,
|
|
409
|
+
f"[{ts}]{a.tier}[/{ts}]" if ts else a.tier,
|
|
410
|
+
counts,
|
|
411
|
+
_dimension(a.hardening),
|
|
412
|
+
_dimension(a.attack_surface),
|
|
413
|
+
_confidence_label(a.confidence),
|
|
414
|
+
str(a.scan.fixable_count),
|
|
415
|
+
f"{a.remediation_score}/100",
|
|
416
|
+
_hub_status(a),
|
|
417
|
+
_threat_cell(a),
|
|
418
|
+
)
|
|
419
|
+
console.print(table)
|
|
420
|
+
console.print(
|
|
421
|
+
"[dim]C/H/M = Critical/High/Medium | Hard = hardening (higher is better) | "
|
|
422
|
+
"Surf = attack surface (LOWER is better) | Conf = evidence confidence | "
|
|
423
|
+
"Fix = fixable | Rem = remediation | Tag = confirmed in source registry | "
|
|
424
|
+
"Threat = KEV (actively exploited) / EDB (public exploit) findings, from "
|
|
425
|
+
"CISA KEV, FIRST EPSS and Exploit-DB[/dim]"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _threat_cell(analysis: ImageAnalysis) -> str:
|
|
430
|
+
"""Worst-case KEV/Exploit-DB signal across the image's findings, in one cell.
|
|
431
|
+
|
|
432
|
+
The two exploitability signals -- CISA KEV (actively exploited) and
|
|
433
|
+
Exploit-DB (a public exploit exists) -- were already being fetched and
|
|
434
|
+
folded into the security score, but never shown in this table: a reader
|
|
435
|
+
had no way to tell a score reflected them at all. `-` means neither
|
|
436
|
+
catalogue was consulted for any finding in this image (nothing to show
|
|
437
|
+
yet); `none` means they were consulted and found nothing.
|
|
438
|
+
"""
|
|
439
|
+
vulns = analysis.scan.vulnerabilities
|
|
440
|
+
if not vulns:
|
|
441
|
+
return "[dim]-[/dim]"
|
|
442
|
+
kev = sum(1 for v in vulns if v.kev_status.is_true)
|
|
443
|
+
edb = sum(1 for v in vulns if v.exploitdb_status.is_true)
|
|
444
|
+
if kev and edb:
|
|
445
|
+
return f"[red bold]KEV:{kev}+EDB:{edb}[/red bold]"
|
|
446
|
+
if kev:
|
|
447
|
+
return f"[red bold]KEV:{kev}[/red bold]"
|
|
448
|
+
if edb:
|
|
449
|
+
return f"[yellow]EDB:{edb}[/yellow]"
|
|
450
|
+
checked = any(
|
|
451
|
+
v.kev_status is not Tristate.UNKNOWN or v.exploitdb_status is not Tristate.UNKNOWN
|
|
452
|
+
for v in vulns
|
|
453
|
+
)
|
|
454
|
+
return "[dim]none[/dim]" if checked else "[dim]-[/dim]"
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _dimension(report: DimensionReport) -> str:
|
|
458
|
+
"""Render a dimension, or say plainly that too little was determined.
|
|
459
|
+
|
|
460
|
+
A score computed from two facts out of nine is not a measurement, and
|
|
461
|
+
printing it as a number would let the reader treat it as one. Below the
|
|
462
|
+
reportable threshold the cell says `n/a` and the coverage is shown in
|
|
463
|
+
the details section instead.
|
|
464
|
+
"""
|
|
465
|
+
if not report.reportable:
|
|
466
|
+
return "[dim]n/a[/dim]"
|
|
467
|
+
return f"{report.score:.0f}"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
#: Colours track trust, not quality: UNVERIFIED is red because nothing was
|
|
471
|
+
#: measured, which is a stronger warning than a low score.
|
|
472
|
+
_CONFIDENCE_STYLES = {
|
|
473
|
+
Confidence.HIGH: "green",
|
|
474
|
+
Confidence.MEDIUM: "yellow",
|
|
475
|
+
Confidence.LOW: "red",
|
|
476
|
+
Confidence.UNVERIFIED: "bold white on red",
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _confidence_label(level: Confidence) -> str:
|
|
481
|
+
style = _CONFIDENCE_STYLES.get(level, "")
|
|
482
|
+
text = level.value[:4]
|
|
483
|
+
return f"[{style}]{text}[/{style}]" if style else text
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _print_why(analyses: list[ImageAnalysis]) -> None:
|
|
487
|
+
"""State the case for the top pick, and what it costs.
|
|
488
|
+
|
|
489
|
+
A ranked table answers "which one"; it never answers "why that one".
|
|
490
|
+
Without this section the reader is asked to trust a number whose
|
|
491
|
+
derivation is invisible -- which is exactly the failure mode this tool
|
|
492
|
+
exists to avoid in other people's scanners.
|
|
493
|
+
"""
|
|
494
|
+
if not analyses:
|
|
495
|
+
return
|
|
496
|
+
best = analyses[0]
|
|
497
|
+
console.print(f"\n[bold]Why {safe(best.image.full_reference)}?[/bold]")
|
|
498
|
+
for reason in best.why[:10]:
|
|
499
|
+
console.print(f" [green]+[/green] {safe(reason)}")
|
|
500
|
+
if best.trade_offs:
|
|
501
|
+
console.print("\n[bold]Trade-offs[/bold]")
|
|
502
|
+
for cost in best.trade_offs[:10]:
|
|
503
|
+
console.print(f" [yellow]![/yellow] {safe(cost)}")
|
|
504
|
+
|
|
505
|
+
_print_verdict(best)
|
|
506
|
+
|
|
507
|
+
facts = best.facts
|
|
508
|
+
console.print(
|
|
509
|
+
f"\n[dim]Evidence: {facts.determined_count} fact(s) determined | "
|
|
510
|
+
f"hardening coverage {best.hardening.coverage:.0%} | "
|
|
511
|
+
f"scanner {safe(best.scan.scanner)}[/dim]"
|
|
512
|
+
)
|
|
513
|
+
if best.image.digest_known:
|
|
514
|
+
console.print(
|
|
515
|
+
f"[dim]Deploy this exact image: {best.pinned_reference}[/dim]", soft_wrap=True
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _print_verdict(analysis: ImageAnalysis) -> None:
|
|
520
|
+
"""State the verdict and the evidence behind it, in that order.
|
|
521
|
+
|
|
522
|
+
The failure this prevents is the one the whole project is organised
|
|
523
|
+
around: a reader glancing at a table and reading an empty findings
|
|
524
|
+
column as "clean". An image nobody could scan and an image with nothing
|
|
525
|
+
wrong occupy the same row shape, so the difference is spelled out --
|
|
526
|
+
the confidence level, what is missing, and whether the thing may go to
|
|
527
|
+
production at all.
|
|
528
|
+
"""
|
|
529
|
+
level = analysis.confidence
|
|
530
|
+
style = _CONFIDENCE_STYLES.get(level, "")
|
|
531
|
+
console.print(f"\n[{style}]{level.value}[/{style}]" if style else f"\n{level.value}")
|
|
532
|
+
|
|
533
|
+
# At HIGH the reasons are what was verified; below it they are what is
|
|
534
|
+
# missing. Same list, opposite meaning, so the heading says which.
|
|
535
|
+
console.print(f" [dim]{'Evidence:' if level is Confidence.HIGH else 'Evidence gaps:'}[/dim]")
|
|
536
|
+
for reason in analysis.confidence_reasons:
|
|
537
|
+
console.print(f" - {safe(reason)}")
|
|
538
|
+
|
|
539
|
+
if analysis.production_ready:
|
|
540
|
+
console.print(" [green]Production ready[/green]")
|
|
541
|
+
return
|
|
542
|
+
console.print(" [red]Not production ready[/red]")
|
|
543
|
+
for reason in analysis.readiness_reasons:
|
|
544
|
+
console.print(f" [red]x[/red] {safe(reason)}")
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _print_details(analyses: list[ImageAnalysis]) -> None:
|
|
548
|
+
"""Per-image registry link and the scan evidence backing its score.
|
|
549
|
+
|
|
550
|
+
Both are listed below the table rather than in it: URLs and file paths
|
|
551
|
+
are far too wide for a terminal column, and each image needs its *own*
|
|
552
|
+
evidence path, not just a pointer to the aggregate manifest.
|
|
553
|
+
"""
|
|
554
|
+
if not analyses:
|
|
555
|
+
return
|
|
556
|
+
console.print("\n[bold]Details[/bold]")
|
|
557
|
+
for i, a in enumerate(analyses, 1):
|
|
558
|
+
console.print(
|
|
559
|
+
f" {i}. [cyan]{safe(a.image.full_reference)}[/cyan] [dim]{safe(a.image.source)}[/dim]"
|
|
560
|
+
)
|
|
561
|
+
if a.hub_url:
|
|
562
|
+
# soft_wrap keeps the URL on one line so it stays copy-pasteable.
|
|
563
|
+
console.print(f" link: [link={a.hub_url}]{a.hub_url}[/link]", soft_wrap=True)
|
|
564
|
+
if a.evidence_paths:
|
|
565
|
+
for scanner, path in sorted(a.evidence_paths.items()):
|
|
566
|
+
note = _shared_scan_note(a, path)
|
|
567
|
+
console.print(f" {scanner + ':':9} [dim]{path}{note}[/dim]", soft_wrap=True)
|
|
568
|
+
else:
|
|
569
|
+
console.print(" [dim]evidence: not recorded[/dim]")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _shared_scan_note(analysis: ImageAnalysis, path: str) -> str:
|
|
573
|
+
"""Flag evidence produced under a sibling tag's name.
|
|
574
|
+
|
|
575
|
+
Tags that share a manifest digest are scanned once and share the
|
|
576
|
+
result, so the file can be named for whichever tag triggered the scan.
|
|
577
|
+
That is correct -- they are the same image -- but without saying so the
|
|
578
|
+
path looks like it belongs to the wrong image.
|
|
579
|
+
"""
|
|
580
|
+
if not path:
|
|
581
|
+
return ""
|
|
582
|
+
own_prefix = f"{slugify_reference(analysis.image.full_reference)}__"
|
|
583
|
+
return "" if Path(path).name.startswith(own_prefix) else " (shared digest)"
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _print_tier_warnings(analyses: list[ImageAnalysis]) -> None:
|
|
587
|
+
"""Surface tiers that oblige the reader to act.
|
|
588
|
+
|
|
589
|
+
The advice comes from `SecurityTier`, so the terminal states the
|
|
590
|
+
domain's rule rather than a copy of it that can drift.
|
|
591
|
+
"""
|
|
592
|
+
flagged = [
|
|
593
|
+
(a, SecurityTier.ADVICE.get(Tier(a.tier), ""))
|
|
594
|
+
for a in analyses
|
|
595
|
+
if not a.production_ready or Tier(a.tier) in SecurityTier.ADVICE
|
|
596
|
+
]
|
|
597
|
+
flagged = [(a, advice) for a, advice in flagged if advice]
|
|
598
|
+
if not flagged:
|
|
599
|
+
return
|
|
600
|
+
console.print("\n[bold yellow]! Requires review[/bold yellow]")
|
|
601
|
+
for a, advice in flagged:
|
|
602
|
+
console.print(f" {safe(a.image.full_reference)} [dim]Tier {safe(a.tier)}: {advice}[/dim]")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _print_divergences(analyses: list[ImageAnalysis]) -> None:
|
|
606
|
+
disputed = [a for a in analyses if a.scan_divergence]
|
|
607
|
+
if not disputed:
|
|
608
|
+
return
|
|
609
|
+
console.print("\n[bold yellow]! Scanner divergence[/bold yellow]")
|
|
610
|
+
for a in disputed:
|
|
611
|
+
console.print(f" {safe(a.image.full_reference)}: [dim]{safe(a.scan_divergence)}[/dim]")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _print_unverified(result: AnalysisResult) -> None:
|
|
615
|
+
"""List the tags that could not be scanned, grouped by classified cause.
|
|
616
|
+
|
|
617
|
+
Noventa e três linhas repetindo `ERROR: FATAL Fatal error run error: init
|
|
618
|
+
error: DB error: error in v...` não é diagnóstico: é o mesmo prefixo
|
|
619
|
+
cortado noventa e três vezes, sem nomear causa nenhuma. O resumo por
|
|
620
|
+
causa em cima diz de imediato que se trata de *um* problema -- o banco de
|
|
621
|
+
vulnerabilidades -- e não de noventa e três imagens ruins.
|
|
622
|
+
"""
|
|
623
|
+
if not result.unverified:
|
|
624
|
+
return
|
|
625
|
+
console.print("\n[bold yellow]! Unverified (technical error)[/bold yellow]")
|
|
626
|
+
console.print(
|
|
627
|
+
"[dim] These tags were never scored -- no successful scan, no recommendation.[/dim]"
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
by_kind = Counter(item.kind for item in result.unverified)
|
|
631
|
+
console.print(
|
|
632
|
+
" [bold]Causes:[/bold] "
|
|
633
|
+
+ ", ".join(f"{kind} x{count}" for kind, count in by_kind.most_common())
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
for item in result.unverified[:10]:
|
|
637
|
+
console.print(
|
|
638
|
+
f" {safe(item.image_reference)} "
|
|
639
|
+
f"[dim]{safe(item.kind)}: {safe(short_reason(item.reason))}[/dim]"
|
|
640
|
+
)
|
|
641
|
+
remaining = len(result.unverified) - 10
|
|
642
|
+
if remaining > 0:
|
|
643
|
+
console.print(f" [dim]... and {remaining} more (see log file)[/dim]")
|
|
644
|
+
console.print(" [dim]Run with --verbose for the full scanner output.[/dim]")
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _print_deferred(result: AnalysisResult) -> None:
|
|
648
|
+
"""As tags que a busca achou e o run não mediu, com o motivo.
|
|
649
|
+
|
|
650
|
+
Existe pelo mesmo motivo que `_print_unverified`: uma tabela limpa não
|
|
651
|
+
pode esconder o que ficou de fora. A diferença entre os dois blocos é
|
|
652
|
+
a que importa -- lá estão scans que falharam, aqui estão scans que não
|
|
653
|
+
foram tentados, e o segundo é uma escolha desta ferramenta, não um
|
|
654
|
+
acidente. Por isso o bloco também diz como desfazê-la.
|
|
655
|
+
"""
|
|
656
|
+
if not result.deferred:
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
console.print()
|
|
660
|
+
console.print(
|
|
661
|
+
Panel(
|
|
662
|
+
f"[bold cyan]Not Measured ({result.deferred_count} of "
|
|
663
|
+
f"{result.tags_discovered} tags found)[/bold cyan]",
|
|
664
|
+
expand=False,
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
console.print(
|
|
668
|
+
"[dim]These tags exist and were not scanned. That is not a verdict on "
|
|
669
|
+
"them: nothing here was measured, so nothing here is being claimed.[/dim]"
|
|
670
|
+
)
|
|
671
|
+
console.print()
|
|
672
|
+
|
|
673
|
+
by_reason = Counter(item.reason.value for item in result.deferred)
|
|
674
|
+
for reason, count in by_reason.most_common():
|
|
675
|
+
console.print(f" [cyan]{reason}[/cyan] {count}")
|
|
676
|
+
console.print()
|
|
677
|
+
|
|
678
|
+
for item in result.deferred[:10]:
|
|
679
|
+
console.print(f" [dim]{safe(item.reference)}[/dim] -- {safe(item.detail)}")
|
|
680
|
+
remaining = result.deferred_count - 10
|
|
681
|
+
if remaining > 0:
|
|
682
|
+
console.print(f" [dim]... and {remaining} more (see --format json)[/dim]")
|
|
683
|
+
|
|
684
|
+
console.print()
|
|
685
|
+
console.print(
|
|
686
|
+
"[dim]To measure every tag found: --budget 0, or set scan_budget = 0 "
|
|
687
|
+
"in the config file.[/dim]"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _nothing_could_be_measured(result: AnalysisResult) -> bool:
|
|
692
|
+
"""True when tags were found but not one of them could be scanned.
|
|
693
|
+
|
|
694
|
+
This is the difference between "we looked and nothing was good enough"
|
|
695
|
+
and "we never managed to look". Both end with an empty table, and only
|
|
696
|
+
the second is a technical failure.
|
|
697
|
+
"""
|
|
698
|
+
return result.total_tags_analyzed == 0 and bool(result.unverified)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _measurement_failure_message(result: AnalysisResult) -> str:
|
|
702
|
+
"""Lead with the cause rather than with a verdict about the images.
|
|
703
|
+
|
|
704
|
+
`No suitable images found` on a machine with no scanner installed reads
|
|
705
|
+
as a statement about the tags -- that they were examined and rejected.
|
|
706
|
+
Nothing was examined. Naming the dominant cause up front turns the
|
|
707
|
+
output into something the reader can act on.
|
|
708
|
+
"""
|
|
709
|
+
causes = Counter(item.kind for item in result.unverified)
|
|
710
|
+
dominant, count = causes.most_common(1)[0]
|
|
711
|
+
hint = _CAUSE_HINTS.get(
|
|
712
|
+
dominant, "Run with --verbose for the scanner output, or see the log file above."
|
|
713
|
+
)
|
|
714
|
+
return (
|
|
715
|
+
f"[bold red]No image could be scanned.[/bold red]\n"
|
|
716
|
+
f"[red]All {count} candidate(s) failed with: {dominant}[/red]\n\n"
|
|
717
|
+
f"[bold]Suggested action[/bold]\n {hint}\n\n"
|
|
718
|
+
f"[dim]This is a technical failure, not a security verdict: "
|
|
719
|
+
f"nothing was measured, so nothing can be said about these images.[/dim]"
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
#: What to do about each classified failure cause, in the reader's terms.
|
|
724
|
+
_CAUSE_HINTS = {
|
|
725
|
+
"SCANNER_MISSING": "Install Trivy or Grype, then re-run. `dockerls doctor` checks for both.",
|
|
726
|
+
"DB_INIT_FAILED": (
|
|
727
|
+
"The vulnerability database could not be prepared. Check network access to "
|
|
728
|
+
"ghcr.io, then re-run."
|
|
729
|
+
),
|
|
730
|
+
"TIMEOUT": (
|
|
731
|
+
"Scans exceeded the timeout. Raise DOCKERLS_SCANNER_TIMEOUT, or lower --workers "
|
|
732
|
+
"to reduce contention."
|
|
733
|
+
),
|
|
734
|
+
"RATE_LIMITED": "Rate limited by the registry. Run `dockerls login`, or retry later.",
|
|
735
|
+
"AUTH_REQUIRED": "The registry requires credentials. Run `dockerls login`.",
|
|
736
|
+
"NOT_FOUND": "None of the discovered tags could be pulled. Check the image name.",
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _exit_code(result: AnalysisResult, fail_on: FailOn) -> int:
|
|
741
|
+
items = result.recommendations or result.alternatives
|
|
742
|
+
if items and fail_on != FailOn.NONE:
|
|
743
|
+
counter = _FAIL_ON_COUNT[fail_on]
|
|
744
|
+
if counter(items[0]) > 0:
|
|
745
|
+
return EXIT_ERROR_CODE
|
|
746
|
+
|
|
747
|
+
if result.baseline_met and result.recommendations:
|
|
748
|
+
return EXIT_BASELINE_MET
|
|
749
|
+
if result.alternatives:
|
|
750
|
+
return EXIT_ALTERNATIVES_FOUND
|
|
751
|
+
if result.total_tags_scanned == 0:
|
|
752
|
+
return EXIT_ERROR_CODE
|
|
753
|
+
# Tags were discovered but not one could be scanned. Code 3 is published
|
|
754
|
+
# as "nothing usable was found" -- a statement about the *images*, which
|
|
755
|
+
# a CI gate is entitled to act on. With no scanner installed, or a
|
|
756
|
+
# vulnerability database that would not download, nothing was measured
|
|
757
|
+
# at all, and reporting that as a verdict is exactly the substitution
|
|
758
|
+
# this tool must never make. It is an operational failure: code 1.
|
|
759
|
+
if _nothing_could_be_measured(result):
|
|
760
|
+
return EXIT_ERROR_CODE
|
|
761
|
+
return EXIT_NONE_FOUND
|