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,159 @@
|
|
|
1
|
+
"""`dockerls fleet` -- o retrato de todos os Dockerfiles de uma vez.
|
|
2
|
+
|
|
3
|
+
Cada comando desta ferramenta olha para um artefato. Isso resolve a pergunta
|
|
4
|
+
de quem está com o arquivo aberto e não resolve nenhuma das perguntas de quem
|
|
5
|
+
responde por trinta repositórios: "quantos ainda rodam como root?", "quantos
|
|
6
|
+
fixam a base?", "por onde eu começo?". Sem resposta, a resposta na prática vira
|
|
7
|
+
"por onde alguém reclamar".
|
|
8
|
+
|
|
9
|
+
A saída é uma fila de trabalho, ordenada por violações e desempatada pelo
|
|
10
|
+
caminho -- para que duas varreduras da mesma frota sejam comparáveis.
|
|
11
|
+
|
|
12
|
+
O que este comando não faz está dito na própria saída, e não é modéstia: ele
|
|
13
|
+
lê Dockerfiles, não constrói imagem nem chama scanner. Um "relatório de
|
|
14
|
+
segurança da frota" que na verdade leu arquivos seria exatamente a promessa
|
|
15
|
+
que o resto desta ferramenta existe para não fazer.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
|
|
27
|
+
from dockerls.cli.options import OutputFormat, parse_output_format
|
|
28
|
+
from dockerls.cli.text import safe
|
|
29
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
30
|
+
from dockerls.exit_codes import EXIT_ERROR, EXIT_OK, EXIT_POLICY
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from dockerls.domain.value_objects.fleet import FleetReport
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
_USER_LABEL = {
|
|
38
|
+
Tristate.TRUE: "[green]non-root[/green]",
|
|
39
|
+
Tristate.FALSE: "[red]root[/red]",
|
|
40
|
+
Tristate.UNKNOWN: "[yellow]undetermined[/yellow]",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fleet(
|
|
45
|
+
root: str = typer.Argument(".", help="Root of the tree to scan"),
|
|
46
|
+
policy_path: str | None = typer.Option(
|
|
47
|
+
None,
|
|
48
|
+
"--policy",
|
|
49
|
+
help=(
|
|
50
|
+
"Policy to check every Dockerfile against (default: .dockerls-policy.yaml "
|
|
51
|
+
"at the root, when present). Only rules decidable without a build apply"
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
output_format: str = typer.Option(
|
|
55
|
+
OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
|
|
56
|
+
),
|
|
57
|
+
limit: int = typer.Option(0, "--limit", help="Show only the first N in the queue (0 = all)"),
|
|
58
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Scan a tree of repositories and summarise the state of its Dockerfiles."""
|
|
61
|
+
if no_color:
|
|
62
|
+
console.no_color = True
|
|
63
|
+
fmt = parse_output_format(output_format)
|
|
64
|
+
|
|
65
|
+
from dockerls.application.use_cases.fleet_scan import FleetScanRequest, FleetScanUseCase
|
|
66
|
+
from dockerls.infrastructure.config.policy_file import (
|
|
67
|
+
PolicyFileError,
|
|
68
|
+
find_policy_file,
|
|
69
|
+
load_policy,
|
|
70
|
+
)
|
|
71
|
+
from dockerls.infrastructure.dockerfile_validator import DockerfileValidator
|
|
72
|
+
|
|
73
|
+
target = Path(root)
|
|
74
|
+
if not target.is_dir():
|
|
75
|
+
console.print(f"[red]Error:[/red] {safe(root)} is not a directory")
|
|
76
|
+
raise typer.Exit(EXIT_ERROR)
|
|
77
|
+
|
|
78
|
+
policy_file = Path(policy_path) if policy_path else find_policy_file(target)
|
|
79
|
+
declared = None
|
|
80
|
+
if policy_file is not None:
|
|
81
|
+
if not policy_file.is_file():
|
|
82
|
+
console.print(f"[red]Error:[/red] policy not found: {safe(str(policy_file))}")
|
|
83
|
+
raise typer.Exit(EXIT_ERROR)
|
|
84
|
+
try:
|
|
85
|
+
declared = load_policy(policy_file)
|
|
86
|
+
except PolicyFileError as e:
|
|
87
|
+
console.print(f"[red]Error:[/red] {safe(str(e))}")
|
|
88
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
89
|
+
|
|
90
|
+
report = FleetScanUseCase(DockerfileValidator()).execute(
|
|
91
|
+
FleetScanRequest(root=str(target), policy=declared)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if fmt == OutputFormat.JSON:
|
|
95
|
+
console.print(json.dumps(report.to_dict(), indent=2, ensure_ascii=False), soft_wrap=True)
|
|
96
|
+
else:
|
|
97
|
+
_render(report, limit=limit)
|
|
98
|
+
|
|
99
|
+
# Reprova quando há violação: é o que torna a varredura utilizável como
|
|
100
|
+
# portão de um repositório-guarda-chuva. Sem política declarada não há o
|
|
101
|
+
# que reprovar, e o silêncio aqui não é conformidade.
|
|
102
|
+
raise typer.Exit(EXIT_POLICY if report.total_violations else EXIT_OK)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _render(report: FleetReport, *, limit: int) -> None:
|
|
106
|
+
console.print(f"\n[bold]{safe(report.root)}[/bold]")
|
|
107
|
+
console.print(f"[dim]{safe(report.summary())}[/dim]\n")
|
|
108
|
+
|
|
109
|
+
if not report.total:
|
|
110
|
+
console.print("[yellow]No Dockerfile found under this root.[/yellow]")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
fila = report.worst_first()
|
|
114
|
+
mostrados = fila[:limit] if limit > 0 else fila
|
|
115
|
+
for entry in mostrados:
|
|
116
|
+
if not entry.readable:
|
|
117
|
+
console.print(f" [red]![/red] {safe(entry.path)} [dim]{safe(entry.error)}[/dim]")
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
fixadas = (
|
|
121
|
+
f"{entry.pinned_bases}/{entry.total_bases} pinned"
|
|
122
|
+
if entry.total_bases
|
|
123
|
+
else "no readable FROM"
|
|
124
|
+
)
|
|
125
|
+
cor = "green" if entry.fully_pinned else "yellow"
|
|
126
|
+
console.print(
|
|
127
|
+
f" {safe(entry.path)}\n"
|
|
128
|
+
f" [{cor}]{fixadas}[/{cor}] {_USER_LABEL[entry.nonroot]} "
|
|
129
|
+
f"[dim]{entry.stages} stage(s)[/dim]"
|
|
130
|
+
)
|
|
131
|
+
for violation in entry.violations:
|
|
132
|
+
console.print(
|
|
133
|
+
f" [red]x[/red] {violation.rule} [dim]{safe(violation.message)}[/dim]"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if limit > 0 and len(fila) > limit:
|
|
137
|
+
console.print(f"\n[dim]... and {len(fila) - limit} more (use --limit 0 for all).[/dim]")
|
|
138
|
+
|
|
139
|
+
console.print()
|
|
140
|
+
if report.truncated:
|
|
141
|
+
console.print(
|
|
142
|
+
"[yellow]The scan hit the file or depth ceiling: this picture is incomplete.[/yellow]"
|
|
143
|
+
)
|
|
144
|
+
for path in report.unreadable_paths:
|
|
145
|
+
console.print(f"[yellow]Directory not walked: {safe(path)}[/yellow]")
|
|
146
|
+
|
|
147
|
+
if report.policy_applied:
|
|
148
|
+
console.print(
|
|
149
|
+
f"[bold]{report.with_violations} file(s) with violations, "
|
|
150
|
+
f"{report.total_violations} in total.[/bold]\n"
|
|
151
|
+
"[dim]Only rules decidable without a build were applied; the ones that "
|
|
152
|
+
"need a scan still hold in `dockerls build`.[/dim]"
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
console.print(
|
|
156
|
+
"[dim]No policy declared: nothing was checked against any rule, and that "
|
|
157
|
+
"is not the same as being compliant.[/dim]"
|
|
158
|
+
)
|
|
159
|
+
console.print(f"[dim]{safe(report.caveat())}[/dim]")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from dockerls.cli.dependencies import build_host_guard
|
|
11
|
+
from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
|
|
12
|
+
from dockerls.infrastructure.network.guarded_client import guarded_async_client
|
|
13
|
+
from dockerls.integrations.exploitdb.client import EXPLOITDB_CSV_URL
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
# Exit codes so `health` is usable as a CI gate. They come from the shared
|
|
18
|
+
# contract rather than from literals re-declared here: a probe that cannot
|
|
19
|
+
# reach a dependency is "não sei" (EXIT_ERROR), not a policy verdict.
|
|
20
|
+
EXIT_HEALTHY = EXIT_OK
|
|
21
|
+
EXIT_DEGRADED = EXIT_ERROR
|
|
22
|
+
|
|
23
|
+
# Each probe must be an endpoint the tool actually depends on *and* one that
|
|
24
|
+
# answers 2xx when healthy. `https://hub.docker.com/v2/` was neither: it
|
|
25
|
+
# returns 404 by design, so every single run reported the Hub as degraded.
|
|
26
|
+
# The list tracks the real dependencies -- the image catalogues that feed
|
|
27
|
+
# the scan pipeline and the threat-intel feeds that weight the score.
|
|
28
|
+
ENDPOINTS = {
|
|
29
|
+
"Docker Hub API": "https://hub.docker.com/v2/repositories/library/node/",
|
|
30
|
+
"Chainguard (cgr.dev)": "https://cgr.dev/token?scope=repository:chainguard/node:pull&service=cgr.dev",
|
|
31
|
+
"Distroless (gcr.io)": "https://gcr.io/v2/distroless/base/tags/list",
|
|
32
|
+
"endoflife.date": "https://endoflife.date/api/python.json",
|
|
33
|
+
"CISA KEV": "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
|
|
34
|
+
"EPSS (FIRST)": "https://api.first.org/data/v1/epss?cve=CVE-2021-44228",
|
|
35
|
+
"Exploit-DB catalogue": EXPLOITDB_CSV_URL,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#: Endpoints grandes demais para baixar num probe de saúde. O CSV do
|
|
39
|
+
#: Exploit-DB tem cerca de 10 MB, e `health` pergunta se a fonte responde --
|
|
40
|
+
#: não quer o conteúdo dela. Um HEAD responde exatamente isso.
|
|
41
|
+
_HEAD_ONLY = frozenset({EXPLOITDB_CSV_URL})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def health() -> None:
|
|
45
|
+
"""Check connectivity to external services."""
|
|
46
|
+
raise typer.Exit(asyncio.run(_health()))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _health() -> int:
|
|
50
|
+
console.print("[bold]Service Health Check[/bold]\n")
|
|
51
|
+
|
|
52
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
53
|
+
table.add_column("Service", style="bold")
|
|
54
|
+
table.add_column("Status")
|
|
55
|
+
|
|
56
|
+
degraded = False
|
|
57
|
+
# The URLs above are constants, but the redirects they may answer with
|
|
58
|
+
# are not, and this client follows them. Same policy, same reason as
|
|
59
|
+
# every other client in the tool.
|
|
60
|
+
async with guarded_async_client(
|
|
61
|
+
build_host_guard(), timeout=10, follow_redirects=True
|
|
62
|
+
) as client:
|
|
63
|
+
for name, url in ENDPOINTS.items():
|
|
64
|
+
try:
|
|
65
|
+
resp = await client.head(url) if url in _HEAD_ONLY else await client.get(url)
|
|
66
|
+
except httpx.HTTPError as e:
|
|
67
|
+
# Unreachable is a failure, not a curiosity: DNS, TLS,
|
|
68
|
+
# proxy and timeout errors all land here.
|
|
69
|
+
table.add_row(name, f"[red]Unreachable: {type(e).__name__}[/red]")
|
|
70
|
+
degraded = True
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
if resp.status_code < 400:
|
|
74
|
+
table.add_row(name, f"[green]OK ({resp.status_code})[/green]")
|
|
75
|
+
else:
|
|
76
|
+
table.add_row(name, f"[red]HTTP {resp.status_code}[/red]")
|
|
77
|
+
degraded = True
|
|
78
|
+
|
|
79
|
+
console.print(table)
|
|
80
|
+
if degraded:
|
|
81
|
+
console.print("\n[yellow]One or more services are degraded.[/yellow]")
|
|
82
|
+
return EXIT_DEGRADED
|
|
83
|
+
console.print("\n[green]All services reachable.[/green]")
|
|
84
|
+
return EXIT_HEALTHY
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from dockerls.exit_codes import EXIT_ERROR
|
|
9
|
+
from dockerls.integrations.dockerhub.client import DockerHubClient
|
|
10
|
+
from dockerls.utils.auth import clear_credentials, store_credentials
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def login(
|
|
16
|
+
username: str = typer.Option("", "--username", "-u", prompt="Docker Hub username"),
|
|
17
|
+
token: str = typer.Option("", "--token", "-t", prompt="Docker Hub token", hide_input=True),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Authenticate with Docker Hub. Credentials are stored in your system keyring."""
|
|
20
|
+
if not username or not token:
|
|
21
|
+
console.print("[red]Username and token are required.[/red]")
|
|
22
|
+
raise typer.Exit(EXIT_ERROR)
|
|
23
|
+
|
|
24
|
+
asyncio.run(_login(username, token))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _login(username: str, token: str) -> None:
|
|
28
|
+
client = DockerHubClient(username=username, token=token)
|
|
29
|
+
if not await client.authenticate():
|
|
30
|
+
console.print("[red]Authentication failed. Check your username and token.[/red]")
|
|
31
|
+
raise typer.Exit(EXIT_ERROR)
|
|
32
|
+
|
|
33
|
+
if store_credentials(username, token):
|
|
34
|
+
console.print("[green]Authenticated. Credentials stored securely in keyring.[/green]")
|
|
35
|
+
else:
|
|
36
|
+
console.print(
|
|
37
|
+
"[yellow]Authenticated, but keyring is not available. Set "
|
|
38
|
+
"DOCKERHUB_USERNAME and DOCKERHUB_TOKEN environment variables "
|
|
39
|
+
"to persist credentials instead.[/yellow]"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def logout() -> None:
|
|
44
|
+
"""Remove stored Docker Hub credentials.
|
|
45
|
+
|
|
46
|
+
`login` could store credentials with no supported way to remove them,
|
|
47
|
+
which left `clear_credentials` implemented and unreachable.
|
|
48
|
+
"""
|
|
49
|
+
if clear_credentials():
|
|
50
|
+
console.print("[green]Stored credentials removed.[/green]")
|
|
51
|
+
return
|
|
52
|
+
console.print("[yellow]No stored credentials to remove.[/yellow]")
|
|
53
|
+
raise typer.Exit(EXIT_ERROR)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""`dockerls policy` -- ler o regulamento sem precisar de um build para isso.
|
|
2
|
+
|
|
3
|
+
Uma política que só se manifesta quando um build reprova é uma política que
|
|
4
|
+
ninguém revisa. Este comando existe para as duas perguntas que aparecem antes
|
|
5
|
+
disso: "o que este repositório exige?" e "este arquivo que acabei de escrever
|
|
6
|
+
está válido?".
|
|
7
|
+
|
|
8
|
+
A segunda importa mais do que parece. O carregador recusa chave desconhecida,
|
|
9
|
+
tipo errado e severidade inexistente -- e recusa com razão, porque uma regra
|
|
10
|
+
que não carrega deixa de exigir alguma coisa. Mas descobrir isso no meio de um
|
|
11
|
+
build de dez minutos é caro; descobrir aqui custa um segundo.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
|
|
22
|
+
from dockerls.cli.options import OutputFormat, parse_output_format
|
|
23
|
+
from dockerls.cli.text import safe
|
|
24
|
+
from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
|
|
25
|
+
from dockerls.infrastructure.config.policy_file import (
|
|
26
|
+
DEFAULT_POLICY_FILENAME,
|
|
27
|
+
PolicyFileError,
|
|
28
|
+
find_policy_file,
|
|
29
|
+
load_policy,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
#: O que cada regra exige, em uma linha, para a saída de tabela.
|
|
35
|
+
_DESCRIPTIONS = {
|
|
36
|
+
"fail_on": "fails the build from this severity upwards",
|
|
37
|
+
"max_vulnerabilities": "ceiling on findings per severity",
|
|
38
|
+
"require_scan": "requires that a scanner has run",
|
|
39
|
+
"require_pinned_bases": "requires every FROM pinned by digest",
|
|
40
|
+
"require_nonroot": "requires the image to run without privilege",
|
|
41
|
+
"required_labels": "labels the image must carry",
|
|
42
|
+
"allowed_base_registries": "where bases may come from",
|
|
43
|
+
"require_provenance": "requires verified provenance",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def policy(
|
|
48
|
+
path: str = typer.Argument(
|
|
49
|
+
".", help="Directory containing the policy file, or the file itself"
|
|
50
|
+
),
|
|
51
|
+
output_format: str = typer.Option(
|
|
52
|
+
OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
|
|
53
|
+
),
|
|
54
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Show and validate the policy declared in `.dockerls-policy.yaml`."""
|
|
57
|
+
if no_color:
|
|
58
|
+
console.no_color = True
|
|
59
|
+
fmt = parse_output_format(output_format)
|
|
60
|
+
|
|
61
|
+
target = Path(path)
|
|
62
|
+
if target.is_dir():
|
|
63
|
+
found = find_policy_file(target)
|
|
64
|
+
if found is None:
|
|
65
|
+
if fmt == OutputFormat.JSON:
|
|
66
|
+
console.print(json.dumps({"policy": None}, indent=2), soft_wrap=True)
|
|
67
|
+
else:
|
|
68
|
+
console.print(
|
|
69
|
+
f"[yellow]No {DEFAULT_POLICY_FILENAME} in {safe(str(target))}.[/yellow]\n"
|
|
70
|
+
"[dim]With no declared policy, only the gates the command line "
|
|
71
|
+
"asks for apply -- and a rule that lives on the command line is a "
|
|
72
|
+
"rule every pipeline rewrites by hand.[/dim]"
|
|
73
|
+
)
|
|
74
|
+
raise typer.Exit(EXIT_OK)
|
|
75
|
+
target = found
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
declared = load_policy(target)
|
|
79
|
+
except PolicyFileError as e:
|
|
80
|
+
console.print(f"[red]Error:[/red] {safe(str(e))}")
|
|
81
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
82
|
+
|
|
83
|
+
if fmt == OutputFormat.JSON:
|
|
84
|
+
console.print(
|
|
85
|
+
json.dumps({"file": str(target), "policy": declared.to_dict()}, indent=2),
|
|
86
|
+
soft_wrap=True,
|
|
87
|
+
)
|
|
88
|
+
raise typer.Exit(EXIT_OK)
|
|
89
|
+
|
|
90
|
+
console.print(f"\n[bold]{safe(str(target))}[/bold]\n")
|
|
91
|
+
for key, value in declared.to_dict().items():
|
|
92
|
+
if not value:
|
|
93
|
+
continue
|
|
94
|
+
console.print(
|
|
95
|
+
f" [cyan]{key}[/cyan] [dim]{_DESCRIPTIONS.get(key, '')}[/dim]\n"
|
|
96
|
+
f" {safe(_format(value))}"
|
|
97
|
+
)
|
|
98
|
+
console.print(
|
|
99
|
+
"\n[dim]Checked on every `dockerls build` in this context. Between the "
|
|
100
|
+
"threshold here and the one on the command line, the stricter wins: a file in "
|
|
101
|
+
"the repository cannot switch off a gate the pipeline asked for.[/dim]"
|
|
102
|
+
)
|
|
103
|
+
raise typer.Exit(EXIT_OK)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _format(value: object) -> str:
|
|
107
|
+
if isinstance(value, dict):
|
|
108
|
+
return ", ".join(f"{k}: {v}" for k, v in value.items())
|
|
109
|
+
if isinstance(value, list):
|
|
110
|
+
return ", ".join(str(v) for v in value)
|
|
111
|
+
return str(value)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""`dockerls provenance` -- conferir um documento de procedência, e atestá-lo.
|
|
2
|
+
|
|
3
|
+
O `build --provenance` arquiva um JSON dizendo o que entrou no build e o que
|
|
4
|
+
saiu. Arquivar é metade do controle; a outra metade é alguém conferir antes de
|
|
5
|
+
o artefato seguir adiante -- e era exatamente essa metade que faltava. Um
|
|
6
|
+
documento que ninguém lê descreve com precisão uma imagem que ninguém sabe se
|
|
7
|
+
deveria ter sido publicada.
|
|
8
|
+
|
|
9
|
+
Este comando é essa leitura, e ele faz duas coisas que só fazem sentido juntas:
|
|
10
|
+
|
|
11
|
+
* **Recalcula o veredito** em vez de acreditar no que está escrito. O campo
|
|
12
|
+
`"status": "VERIFIED"` de um arquivo JSON é editável por qualquer pessoa com
|
|
13
|
+
um editor de texto; a comparação entre os digests de antes e depois do build
|
|
14
|
+
não é. Ler o status gravado seria pedir ao documento que se auto-aprovasse.
|
|
15
|
+
* **Recusa por código de saída** quando a procedência não fecha. É o que
|
|
16
|
+
transforma o documento em portão: num workflow, o passo de atestação só roda
|
|
17
|
+
se este comando passar, e uma imagem cuja entrada mudou durante o build nunca
|
|
18
|
+
chega a receber uma assinatura dizendo que veio dali.
|
|
19
|
+
|
|
20
|
+
`--github-output` existe para o último elo: `actions/attest-build-provenance`
|
|
21
|
+
precisa do nome e do digest do artefato, e tirá-los do documento (em vez de
|
|
22
|
+
redigitá-los no YAML) garante que a atestação fala da mesma imagem que o scan
|
|
23
|
+
mediu. Redigitar é onde a cadeia arrebenta sem ninguém perceber.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import typer
|
|
33
|
+
from rich.console import Console
|
|
34
|
+
|
|
35
|
+
from dockerls.cli.options import OutputFormat, parse_output_format
|
|
36
|
+
from dockerls.cli.text import safe
|
|
37
|
+
from dockerls.domain.value_objects.provenance import BuildProvenance, ProvenanceStatus
|
|
38
|
+
from dockerls.exit_codes import EXIT_ERROR, EXIT_OK, EXIT_POLICY
|
|
39
|
+
|
|
40
|
+
console = Console()
|
|
41
|
+
|
|
42
|
+
_COLORS = {
|
|
43
|
+
ProvenanceStatus.VERIFIED: "green",
|
|
44
|
+
ProvenanceStatus.INCOMPLETE: "yellow",
|
|
45
|
+
ProvenanceStatus.INPUT_CHANGED: "red",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def provenance(
|
|
50
|
+
document: str = typer.Argument(..., help="JSON file written by `build --provenance`"),
|
|
51
|
+
output_format: str = typer.Option(
|
|
52
|
+
OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
|
|
53
|
+
),
|
|
54
|
+
github_output: bool = typer.Option(
|
|
55
|
+
False,
|
|
56
|
+
"--github-output",
|
|
57
|
+
help=(
|
|
58
|
+
"Write subject-name and subject-digest to $GITHUB_OUTPUT for "
|
|
59
|
+
"actions/attest-build-provenance to consume"
|
|
60
|
+
),
|
|
61
|
+
),
|
|
62
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Check a provenance document and prepare the attestation."""
|
|
65
|
+
if no_color:
|
|
66
|
+
console.no_color = True
|
|
67
|
+
fmt = parse_output_format(output_format)
|
|
68
|
+
|
|
69
|
+
path = Path(document)
|
|
70
|
+
try:
|
|
71
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
72
|
+
except OSError as e:
|
|
73
|
+
console.print(f"[red]Error:[/red] could not read {safe(str(path))}: {safe(str(e))}")
|
|
74
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
75
|
+
except json.JSONDecodeError as e:
|
|
76
|
+
console.print(f"[red]Error:[/red] {safe(str(path))} is not valid JSON: {safe(str(e))}")
|
|
77
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
78
|
+
|
|
79
|
+
record = BuildProvenance.from_dict(raw)
|
|
80
|
+
subject_name, subject_digest = _subject(record)
|
|
81
|
+
|
|
82
|
+
if fmt == OutputFormat.JSON:
|
|
83
|
+
payload = record.to_dict()
|
|
84
|
+
payload["subject_name"] = subject_name
|
|
85
|
+
payload["subject_digest"] = subject_digest
|
|
86
|
+
payload["attestable"] = record.is_verified and bool(subject_digest)
|
|
87
|
+
console.print(json.dumps(payload, indent=2, ensure_ascii=False), soft_wrap=True)
|
|
88
|
+
else:
|
|
89
|
+
_render(record, subject_name, subject_digest)
|
|
90
|
+
|
|
91
|
+
if github_output:
|
|
92
|
+
_write_github_output(subject_name, subject_digest, record)
|
|
93
|
+
|
|
94
|
+
if not record.is_verified:
|
|
95
|
+
raise typer.Exit(EXIT_POLICY)
|
|
96
|
+
if not subject_digest:
|
|
97
|
+
# Sem digest não há o que atestar: uma assinatura precisa apontar para
|
|
98
|
+
# bytes específicos, e "a imagem com esta tag" não são bytes.
|
|
99
|
+
console.print(
|
|
100
|
+
"[yellow]The document carries no artifact digest: there is no subject to "
|
|
101
|
+
"attest.[/yellow]\n[dim]Publish the image (`build --push`) so the registry "
|
|
102
|
+
"returns the manifest digest.[/dim]"
|
|
103
|
+
)
|
|
104
|
+
raise typer.Exit(EXIT_POLICY)
|
|
105
|
+
raise typer.Exit(EXIT_OK)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _subject(record: BuildProvenance) -> tuple[str, str]:
|
|
109
|
+
"""Nome e digest do que será atestado.
|
|
110
|
+
|
|
111
|
+
O digest do manifesto no registry vem primeiro: é o único identificador
|
|
112
|
+
que outra máquina consegue usar para puxar exatamente estes bytes. O id
|
|
113
|
+
local serve de segundo melhor, e é rotulado como tal na saída.
|
|
114
|
+
"""
|
|
115
|
+
name = record.artifact.published_reference or record.tag
|
|
116
|
+
digest = record.artifact.repo_digest or record.artifact.image_id
|
|
117
|
+
return name, digest
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _render(record: BuildProvenance, subject_name: str, subject_digest: str) -> None:
|
|
121
|
+
color = _COLORS.get(record.status, "white")
|
|
122
|
+
console.print(f"\n[bold]{safe(record.tag or '(no tag)')}[/bold]")
|
|
123
|
+
console.print(f" [{color}]{record.status}[/{color}] [dim]{safe(record.explain())}[/dim]\n")
|
|
124
|
+
|
|
125
|
+
console.print(" [bold]input[/bold]")
|
|
126
|
+
console.print(f" Dockerfile {safe(record.source.dockerfile or '(not measured)')}")
|
|
127
|
+
console.print(
|
|
128
|
+
f" context {safe(record.source.context or '(not measured)')}"
|
|
129
|
+
f" [dim]({record.source.context_files} file(s))[/dim]"
|
|
130
|
+
)
|
|
131
|
+
if record.source.git_revision:
|
|
132
|
+
sujo = " [yellow](with uncommitted changes)[/yellow]" if record.source.git_dirty else ""
|
|
133
|
+
console.print(f" revision {safe(record.source.git_revision)}{sujo}")
|
|
134
|
+
for reference, digest in record.source.base_images.items():
|
|
135
|
+
console.print(f" base {safe(reference)} -> {safe(digest or '(moving tag)')}")
|
|
136
|
+
|
|
137
|
+
console.print("\n [bold]output[/bold]")
|
|
138
|
+
console.print(f" subject {safe(subject_name or '(no name)')}")
|
|
139
|
+
origem = "registry manifest" if record.artifact.repo_digest else "local image id"
|
|
140
|
+
console.print(f" digest {safe(subject_digest or '(no digest)')} [dim]({origem})[/dim]")
|
|
141
|
+
console.print(f" scanner {safe(record.artifact.scanner or '(not recorded)')}\n")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _write_github_output(name: str, digest: str, record: BuildProvenance) -> None:
|
|
145
|
+
"""Publica o sujeito da atestação para o próximo passo do workflow.
|
|
146
|
+
|
|
147
|
+
Fora do Actions a variável não existe, e escrever num caminho arbitrário
|
|
148
|
+
seria efeito colateral fora de contexto: o comando avisa e segue.
|
|
149
|
+
"""
|
|
150
|
+
destination = os.environ.get("GITHUB_OUTPUT", "")
|
|
151
|
+
if not destination:
|
|
152
|
+
console.print(
|
|
153
|
+
"[yellow]$GITHUB_OUTPUT is not set: --github-output only has an effect "
|
|
154
|
+
"inside a GitHub Actions workflow.[/yellow]"
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
linhas = f"subject-name={name}\nsubject-digest={digest}\nprovenance-status={record.status}\n"
|
|
158
|
+
try:
|
|
159
|
+
with Path(destination).open("a", encoding="utf-8") as handle:
|
|
160
|
+
handle.write(linhas)
|
|
161
|
+
except OSError as e:
|
|
162
|
+
console.print(f"[yellow]Could not write to $GITHUB_OUTPUT: {safe(str(e))}[/yellow]")
|