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,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from dockerls.cli.dependencies import build_cache
|
|
11
|
+
from dockerls.exit_codes import EXIT_ERROR
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Coroutine
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
#: `no_args_is_help`: `dockerls cache` with no subcommand used to print a bare
|
|
18
|
+
#: "Missing command." and nothing else, forcing anyone who typed it to know
|
|
19
|
+
#: to add `--help` themselves. This lists the subcommands right there instead.
|
|
20
|
+
cache_app = typer.Typer(help="Manage the scan cache", no_args_is_help=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run(coro: Coroutine[Any, Any, None]) -> None:
|
|
24
|
+
"""Run a cache operation, reporting storage failures as user errors.
|
|
25
|
+
|
|
26
|
+
A corrupt or unreadable cache database is a normal operational state --
|
|
27
|
+
it must not exit with a traceback, especially for `clear`, which is
|
|
28
|
+
exactly what a user reaches for when the cache is broken.
|
|
29
|
+
"""
|
|
30
|
+
# Import tardio: `sqlalchemy.exc` arrasta o SQLAlchemy inteiro, e este
|
|
31
|
+
# módulo é importado no arranque de *toda* invocação do CLI para registrar
|
|
32
|
+
# o subcomando. Aqui dentro ele só é pago por quem realmente mexe no cache.
|
|
33
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
asyncio.run(coro)
|
|
37
|
+
except (OSError, SQLAlchemyError) as e:
|
|
38
|
+
console.print(f"[red]Cache operation failed:[/red] {e}")
|
|
39
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@cache_app.command("clear")
|
|
43
|
+
def cache_clear() -> None:
|
|
44
|
+
"""Clear all cached scan results."""
|
|
45
|
+
_run(_clear())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _clear() -> None:
|
|
49
|
+
cache = build_cache()
|
|
50
|
+
await cache.clear()
|
|
51
|
+
console.print("[green]Cache cleared.[/green]")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@cache_app.command("cleanup")
|
|
55
|
+
def cache_cleanup() -> None:
|
|
56
|
+
"""Remove expired cache entries."""
|
|
57
|
+
_run(_cleanup())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _cleanup() -> None:
|
|
61
|
+
cache = build_cache()
|
|
62
|
+
count = await cache.cleanup_expired()
|
|
63
|
+
console.print(f"[green]Removed {count} expired entries.[/green]")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@cache_app.command("stats")
|
|
67
|
+
def cache_stats() -> None:
|
|
68
|
+
"""Show cache size, entry count, and how much is reclaimable."""
|
|
69
|
+
_run(_stats())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def _stats() -> None:
|
|
73
|
+
"""Report what the cache is holding.
|
|
74
|
+
|
|
75
|
+
Entries expire lazily -- a stale row is dropped when it is next read --
|
|
76
|
+
so a cache can carry entries for tags nobody asks about again. Without a
|
|
77
|
+
way to see that, "why is this directory large" and "would cleanup help"
|
|
78
|
+
were both unanswerable.
|
|
79
|
+
"""
|
|
80
|
+
cache = build_cache()
|
|
81
|
+
stats = await cache.stats()
|
|
82
|
+
|
|
83
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
84
|
+
table.add_column("Field", style="bold")
|
|
85
|
+
table.add_column("Value")
|
|
86
|
+
table.add_row("Location", stats.path)
|
|
87
|
+
table.add_row("Entries", str(stats.total))
|
|
88
|
+
table.add_row("Expired (reclaimable)", str(stats.expired))
|
|
89
|
+
table.add_row("Size on disk", _human_bytes(stats.size_bytes))
|
|
90
|
+
console.print(table)
|
|
91
|
+
|
|
92
|
+
if stats.expired:
|
|
93
|
+
console.print(
|
|
94
|
+
f"\n[dim]Run `dockerls cache cleanup` to remove {stats.expired} expired entries.[/dim]"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _human_bytes(size: int) -> str:
|
|
99
|
+
value = float(size)
|
|
100
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
101
|
+
if value < 1024 or unit == "GB":
|
|
102
|
+
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
|
|
103
|
+
value /= 1024
|
|
104
|
+
return f"{value:.1f} GB" # pragma: no cover - unreachable, loop returns first
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from dockerls.cli.dependencies import build_compare_use_case
|
|
12
|
+
from dockerls.cli.scan_failure import describe_scan_failure
|
|
13
|
+
from dockerls.cli.text import safe
|
|
14
|
+
from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from dockerls.application.dto.analysis import ComparisonResult
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
# Exit codes, em ordem de severidade:
|
|
22
|
+
# 0 = comparação completa: toda imagem pedida foi escaneada
|
|
23
|
+
# 1 = erro rígido: uso inválido, ou nenhuma imagem pôde ser escaneada
|
|
24
|
+
# 2 = comparação parcial: 2+ escanearam, 1+ falharam
|
|
25
|
+
# 3 = dado insuficiente: menos de duas imagens escaneadas, nada a comparar
|
|
26
|
+
# 0 e 1 vêm do contrato compartilhado; 2 e 3 são próprios de `compare` --
|
|
27
|
+
# a diferença entre "comparei tudo" e "comparei o que deu" é justamente o
|
|
28
|
+
# que um portão de CI precisa poder distinguir.
|
|
29
|
+
EXIT_COMPLETE = EXIT_OK
|
|
30
|
+
EXIT_ERROR_CODE = EXIT_ERROR
|
|
31
|
+
EXIT_PARTIAL = 2
|
|
32
|
+
EXIT_INSUFFICIENT = 3
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def compare(
|
|
36
|
+
images: list[str] = typer.Argument(help="Two or more image references to compare"),
|
|
37
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Compare security posture of multiple Docker images.
|
|
40
|
+
|
|
41
|
+
Exit codes: 0 = every image was scanned and compared; 1 = invalid use, or
|
|
42
|
+
no image could be scanned at all; 2 = partial comparison, at least two
|
|
43
|
+
images were scanned and at least one failed; 3 = fewer than two images
|
|
44
|
+
could be scanned, so there is nothing to compare.
|
|
45
|
+
|
|
46
|
+
An image that could not be scanned is never given a score or a tier: it
|
|
47
|
+
is listed separately, with the classified reason.
|
|
48
|
+
"""
|
|
49
|
+
if no_color:
|
|
50
|
+
console.no_color = True
|
|
51
|
+
if len(images) < 2:
|
|
52
|
+
console.print("[red]Provide at least two images to compare.[/red]")
|
|
53
|
+
raise typer.Exit(EXIT_ERROR_CODE)
|
|
54
|
+
asyncio.run(_compare(images))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _compare(images: list[str]) -> None:
|
|
58
|
+
use_case = await build_compare_use_case()
|
|
59
|
+
try:
|
|
60
|
+
result = await use_case.execute(images)
|
|
61
|
+
except ValueError as e:
|
|
62
|
+
console.print(f"[red]Scan failed: {e}[/red]")
|
|
63
|
+
raise typer.Exit(EXIT_ERROR_CODE) from e
|
|
64
|
+
|
|
65
|
+
# `result.images` já chega filtrado pelo use case: só entra aqui quem
|
|
66
|
+
# tem scan verificado. A tabela abaixo, portanto, não tem como exibir
|
|
67
|
+
# um score que ninguém mediu.
|
|
68
|
+
measured = result.images
|
|
69
|
+
|
|
70
|
+
if not measured:
|
|
71
|
+
# Nada foi medido. Não é um veredito sobre as imagens -- é a
|
|
72
|
+
# ausência de um --, e o código 1 diz isso: falha de execução.
|
|
73
|
+
_print_failures(result)
|
|
74
|
+
console.print(
|
|
75
|
+
"\n[bold red]No image could be scanned.[/bold red]\n"
|
|
76
|
+
"[dim]This is a technical failure, not a security verdict: nothing was "
|
|
77
|
+
"measured, so nothing can be said about these images.[/dim]"
|
|
78
|
+
)
|
|
79
|
+
raise typer.Exit(EXIT_ERROR_CODE)
|
|
80
|
+
|
|
81
|
+
if len(measured) < 2:
|
|
82
|
+
# Uma imagem medida não é uma comparação. Mostrar a tabela com uma
|
|
83
|
+
# linha só sugeriria um vencedor onde não houve disputa.
|
|
84
|
+
_print_failures(result)
|
|
85
|
+
console.print(
|
|
86
|
+
f"\n[bold yellow]Not enough data to compare.[/bold yellow]\n"
|
|
87
|
+
f"[yellow]Only {safe(measured[0].image.full_reference)} could be scanned; "
|
|
88
|
+
f"a comparison needs at least two.[/yellow]"
|
|
89
|
+
)
|
|
90
|
+
raise typer.Exit(EXIT_INSUFFICIENT)
|
|
91
|
+
|
|
92
|
+
console.print(Panel("[bold]Image Comparison[/bold]", expand=False))
|
|
93
|
+
|
|
94
|
+
table = Table()
|
|
95
|
+
table.add_column("Image", style="cyan")
|
|
96
|
+
table.add_column("Score", justify="right", style="green")
|
|
97
|
+
table.add_column("Tier", justify="center")
|
|
98
|
+
table.add_column("Critical", justify="right", style="red")
|
|
99
|
+
table.add_column("High", justify="right", style="yellow")
|
|
100
|
+
table.add_column("Medium", justify="right")
|
|
101
|
+
table.add_column("Total Vulns", justify="right")
|
|
102
|
+
table.add_column("Fixable", justify="right")
|
|
103
|
+
table.add_column("Remediation", justify="right")
|
|
104
|
+
|
|
105
|
+
for a in measured:
|
|
106
|
+
table.add_row(
|
|
107
|
+
safe(a.image.full_reference),
|
|
108
|
+
str(a.security_score),
|
|
109
|
+
a.tier,
|
|
110
|
+
str(a.scan.critical_count),
|
|
111
|
+
str(a.scan.high_count),
|
|
112
|
+
str(a.scan.medium_count),
|
|
113
|
+
str(a.scan.total_count),
|
|
114
|
+
str(a.scan.fixable_count),
|
|
115
|
+
f"{a.remediation_score}/100",
|
|
116
|
+
)
|
|
117
|
+
console.print(table)
|
|
118
|
+
|
|
119
|
+
_print_verdict(result)
|
|
120
|
+
_print_failures(result)
|
|
121
|
+
|
|
122
|
+
if result.unverified:
|
|
123
|
+
console.print(
|
|
124
|
+
f"\n[dim]Partial comparison: {len(measured)} of "
|
|
125
|
+
f"{len(measured) + len(result.unverified)} image(s) were measured.[/dim]"
|
|
126
|
+
)
|
|
127
|
+
raise typer.Exit(EXIT_PARTIAL)
|
|
128
|
+
raise typer.Exit(EXIT_COMPLETE)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _print_failures(result: ComparisonResult) -> None:
|
|
132
|
+
"""As imagens que não puderam ser medidas, com a causa classificada.
|
|
133
|
+
|
|
134
|
+
Seção própria, e nunca uma linha na tabela: o leitor precisa ver de
|
|
135
|
+
imediato que estas não foram comparadas com as outras, e sim deixadas
|
|
136
|
+
de fora por falta de medição.
|
|
137
|
+
"""
|
|
138
|
+
if not result.unverified:
|
|
139
|
+
return
|
|
140
|
+
console.print("\n[bold yellow]Failed (not compared)[/bold yellow]")
|
|
141
|
+
console.print("[dim] These images were never scored -- no successful scan.[/dim]")
|
|
142
|
+
for item in result.unverified:
|
|
143
|
+
console.print(
|
|
144
|
+
f" {safe(item.image_reference)} "
|
|
145
|
+
f"[dim]{safe(describe_scan_failure(item.kind, item.reason))}[/dim]"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _print_verdict(result: ComparisonResult) -> None:
|
|
150
|
+
"""Vencedor primeiro, depois cada perdedor com o próprio delta.
|
|
151
|
+
|
|
152
|
+
A versão anterior juntava tudo numa linha -- vencedor, score absoluto e
|
|
153
|
+
diferença separados por ponto e vírgula --, e o `-36.0 points` do final
|
|
154
|
+
lia como um score negativo em vez de uma distância até o vencedor.
|
|
155
|
+
"""
|
|
156
|
+
winner = next((a for a in result.images if a.image.full_reference == result.winner), None)
|
|
157
|
+
if winner is None:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
console.print(
|
|
161
|
+
f"\n[bold green]Winner: {safe(winner.image.full_reference)}[/bold green] "
|
|
162
|
+
f"[dim](Score {winner.security_score}, Tier {winner.tier})[/dim]"
|
|
163
|
+
)
|
|
164
|
+
for a in result.images:
|
|
165
|
+
if a.image.full_reference == result.winner:
|
|
166
|
+
continue
|
|
167
|
+
delta = a.security_score - winner.security_score
|
|
168
|
+
console.print(
|
|
169
|
+
f" {safe(a.image.full_reference)} Score {a.security_score}, Tier {a.tier} "
|
|
170
|
+
f"[dim]({delta:+.1f} vs. winner)[/dim]"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if result.common_vulns:
|
|
174
|
+
console.print(f"\n[bold]Shared vulnerabilities:[/bold] {len(result.common_vulns)}")
|
|
175
|
+
for reference, vulns in result.unique_vulns.items():
|
|
176
|
+
if vulns:
|
|
177
|
+
console.print(f"[dim]Unique to {safe(reference)}: {len(vulns)}[/dim]")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""`dockerls controls` -- the published controls behind each Dockerfile rule.
|
|
2
|
+
|
|
3
|
+
`analyze-dockerfile` cites a control next to every finding it reports, which
|
|
4
|
+
answers "why is this a problem" at the moment the reader has to act. It does
|
|
5
|
+
not answer the question that comes before that one: *what does this tool
|
|
6
|
+
check, and on whose authority*. Somebody deciding whether to put DockerLs in
|
|
7
|
+
front of a build has to be able to read the whole rulebook without first
|
|
8
|
+
producing a Dockerfile that fails.
|
|
9
|
+
|
|
10
|
+
This command is that rulebook. It also states, per rule, whether the
|
|
11
|
+
citation exists at all: a rule backed by CIS or NIST and a rule backed by
|
|
12
|
+
this project's own judgement are both legitimate, but they are not the same
|
|
13
|
+
claim, and collapsing them would be the same kind of overstatement as
|
|
14
|
+
reporting an unmeasured image as clean.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
|
|
24
|
+
from dockerls.cli.options import OutputFormat, parse_output_format
|
|
25
|
+
from dockerls.cli.text import safe
|
|
26
|
+
from dockerls.domain.security_controls import RULE_MAPPINGS, RuleMapping, mapping_for
|
|
27
|
+
from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
#: Shown when a rule carries no published control. Spelled out rather than
|
|
32
|
+
#: left blank, because a blank reads as "citation omitted".
|
|
33
|
+
UNDOCUMENTED_NOTE = "DockerLs guidance; no published control in the catalogue covers this rule"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def controls(
|
|
37
|
+
rule: str = typer.Argument(
|
|
38
|
+
"", help="Rule to explain (e.g. DF002). Omit to list the whole catalogue."
|
|
39
|
+
),
|
|
40
|
+
output_format: str = typer.Option(
|
|
41
|
+
OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
|
|
42
|
+
),
|
|
43
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Show the security controls each Dockerfile rule implements."""
|
|
46
|
+
if no_color:
|
|
47
|
+
console.no_color = True
|
|
48
|
+
fmt = parse_output_format(output_format)
|
|
49
|
+
|
|
50
|
+
if rule:
|
|
51
|
+
mapping = mapping_for(rule)
|
|
52
|
+
if mapping is None:
|
|
53
|
+
_report_unknown(rule, fmt)
|
|
54
|
+
raise typer.Exit(EXIT_ERROR)
|
|
55
|
+
selected: tuple[RuleMapping, ...] = (mapping,)
|
|
56
|
+
else:
|
|
57
|
+
selected = RULE_MAPPINGS
|
|
58
|
+
|
|
59
|
+
if fmt == OutputFormat.JSON:
|
|
60
|
+
console.print(json.dumps([_as_dict(m) for m in selected], indent=2), soft_wrap=True)
|
|
61
|
+
else:
|
|
62
|
+
_render(selected, detailed=bool(rule))
|
|
63
|
+
raise typer.Exit(EXIT_OK)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _as_dict(mapping: RuleMapping) -> dict[str, object]:
|
|
67
|
+
return {
|
|
68
|
+
"rule_id": mapping.rule_id,
|
|
69
|
+
"summary": mapping.summary,
|
|
70
|
+
"rationale": mapping.rationale,
|
|
71
|
+
"documented": mapping.is_documented,
|
|
72
|
+
"controls": [
|
|
73
|
+
{
|
|
74
|
+
"source": control.source.value,
|
|
75
|
+
"identifier": control.identifier,
|
|
76
|
+
"title": control.title,
|
|
77
|
+
"reference": str(control),
|
|
78
|
+
}
|
|
79
|
+
for control in mapping.controls
|
|
80
|
+
],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _report_unknown(rule: str, output_format: OutputFormat) -> None:
|
|
85
|
+
known = ", ".join(mapping.rule_id for mapping in RULE_MAPPINGS)
|
|
86
|
+
message = f"regra desconhecida: {rule}"
|
|
87
|
+
if output_format == OutputFormat.JSON:
|
|
88
|
+
console.print(json.dumps({"error": message, "known_rules": known.split(", ")}))
|
|
89
|
+
return
|
|
90
|
+
console.print(f"[red]Error:[/red] {safe(message)}")
|
|
91
|
+
console.print(f"[dim]Regras catalogadas: {known}[/dim]")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _render(mappings: tuple[RuleMapping, ...], *, detailed: bool) -> None:
|
|
95
|
+
for mapping in mappings:
|
|
96
|
+
console.print(f"[cyan bold]{mapping.rule_id}[/cyan bold] {safe(mapping.summary)}")
|
|
97
|
+
if detailed:
|
|
98
|
+
console.print(f" [dim]{safe(mapping.rationale)}[/dim]")
|
|
99
|
+
for control in mapping.controls:
|
|
100
|
+
console.print(f" [green]->[/green] {safe(str(control))}")
|
|
101
|
+
if not mapping.is_documented:
|
|
102
|
+
console.print(f" [dim]-> {UNDOCUMENTED_NOTE}[/dim]")
|
|
103
|
+
console.print()
|
|
104
|
+
|
|
105
|
+
if not detailed:
|
|
106
|
+
documented = sum(1 for mapping in mappings if mapping.is_documented)
|
|
107
|
+
console.print(
|
|
108
|
+
f"[dim]{documented} of {len(mappings)} rules cite a published control. "
|
|
109
|
+
"Run `dockerls controls DF002` to see the rationale for one rule.[/dim]"
|
|
110
|
+
)
|