dockerls 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dockerls/__init__.py +31 -0
- dockerls/application/__init__.py +0 -0
- dockerls/application/dto/__init__.py +3 -0
- dockerls/application/dto/analysis.py +257 -0
- dockerls/application/services/__init__.py +0 -0
- dockerls/application/services/alternatives_lookup.py +167 -0
- dockerls/application/services/composite_repository.py +106 -0
- dockerls/application/services/cross_validation.py +233 -0
- dockerls/application/services/ecosystems.py +350 -0
- dockerls/application/services/fallback_scanner.py +97 -0
- dockerls/application/services/hardening_analysis.py +174 -0
- dockerls/application/services/migration.py +297 -0
- dockerls/application/services/progress.py +56 -0
- dockerls/application/services/remediation.py +321 -0
- dockerls/application/services/scan_history_store.py +88 -0
- dockerls/application/services/scanner_factory.py +88 -0
- dockerls/application/services/source_registry.py +151 -0
- dockerls/application/services/tag_history_store.py +76 -0
- dockerls/application/services/teardown.py +50 -0
- dockerls/application/services/verdict.py +298 -0
- dockerls/application/services/version_discovery.py +108 -0
- dockerls/application/use_cases/__init__.py +0 -0
- dockerls/application/use_cases/analyze_dockerfile.py +103 -0
- dockerls/application/use_cases/analyze_image.py +173 -0
- dockerls/application/use_cases/build_image.py +1795 -0
- dockerls/application/use_cases/compare_images.py +91 -0
- dockerls/application/use_cases/fleet_scan.py +240 -0
- dockerls/application/use_cases/recommend_images.py +1078 -0
- dockerls/application/use_cases/registry_audit.py +133 -0
- dockerls/application/use_cases/search_images.py +23 -0
- dockerls/application/use_cases/upgrade_base.py +167 -0
- dockerls/cache/__init__.py +0 -0
- dockerls/cache/sqlite_cache.py +184 -0
- dockerls/cli/__init__.py +0 -0
- dockerls/cli/analysis_baseline.py +98 -0
- dockerls/cli/app.py +294 -0
- dockerls/cli/commands/__init__.py +0 -0
- dockerls/cli/commands/advisor.py +262 -0
- dockerls/cli/commands/alternatives.py +291 -0
- dockerls/cli/commands/analyze.py +429 -0
- dockerls/cli/commands/analyze_dockerfile.py +104 -0
- dockerls/cli/commands/base_cmd.py +244 -0
- dockerls/cli/commands/base_image.py +551 -0
- dockerls/cli/commands/build.py +1300 -0
- dockerls/cli/commands/cache_cmd.py +104 -0
- dockerls/cli/commands/compare.py +177 -0
- dockerls/cli/commands/controls.py +110 -0
- dockerls/cli/commands/doctor.py +566 -0
- dockerls/cli/commands/export.py +81 -0
- dockerls/cli/commands/fleet.py +159 -0
- dockerls/cli/commands/health.py +84 -0
- dockerls/cli/commands/login.py +53 -0
- dockerls/cli/commands/policy_cmd.py +111 -0
- dockerls/cli/commands/provenance_cmd.py +162 -0
- dockerls/cli/commands/recommend.py +761 -0
- dockerls/cli/commands/registry_audit_cmd.py +103 -0
- dockerls/cli/commands/sbom.py +144 -0
- dockerls/cli/commands/search.py +86 -0
- dockerls/cli/commands/verify.py +115 -0
- dockerls/cli/commands/version.py +12 -0
- dockerls/cli/commands/vex_cmd.py +117 -0
- dockerls/cli/dependencies.py +530 -0
- dockerls/cli/image_names.py +79 -0
- dockerls/cli/options.py +42 -0
- dockerls/cli/progress.py +145 -0
- dockerls/cli/publish_prompt.py +123 -0
- dockerls/cli/rendering.py +214 -0
- dockerls/cli/runtime.py +65 -0
- dockerls/cli/scan_failure.py +71 -0
- dockerls/cli/text.py +39 -0
- dockerls/cli/validators.py +35 -0
- dockerls/cli/vulnerability_view.py +154 -0
- dockerls/domain/__init__.py +0 -0
- dockerls/domain/entities/__init__.py +75 -0
- dockerls/domain/entities/declared_metadata.py +147 -0
- dockerls/domain/entities/dockerfile_analysis.py +318 -0
- dockerls/domain/entities/image.py +109 -0
- dockerls/domain/entities/image_facts.py +137 -0
- dockerls/domain/entities/recommendation.py +33 -0
- dockerls/domain/entities/scan_result.py +129 -0
- dockerls/domain/entities/vulnerability.py +232 -0
- dockerls/domain/interfaces/__init__.py +17 -0
- dockerls/domain/interfaces/cache_store.py +18 -0
- dockerls/domain/interfaces/dockerfile_validator.py +99 -0
- dockerls/domain/interfaces/eol_checker.py +11 -0
- dockerls/domain/interfaces/image_repository.py +15 -0
- dockerls/domain/interfaces/scanner.py +15 -0
- dockerls/domain/security_controls.py +362 -0
- dockerls/domain/value_objects/__init__.py +49 -0
- dockerls/domain/value_objects/attack_surface.py +198 -0
- dockerls/domain/value_objects/base_recipe.py +600 -0
- dockerls/domain/value_objects/base_upgrade.py +292 -0
- dockerls/domain/value_objects/build_labels.py +99 -0
- dockerls/domain/value_objects/build_policy.py +412 -0
- dockerls/domain/value_objects/confidence.py +156 -0
- dockerls/domain/value_objects/fleet.py +174 -0
- dockerls/domain/value_objects/gate.py +327 -0
- dockerls/domain/value_objects/hardening.py +303 -0
- dockerls/domain/value_objects/image_reference.py +90 -0
- dockerls/domain/value_objects/inheritance.py +352 -0
- dockerls/domain/value_objects/network_policy.py +280 -0
- dockerls/domain/value_objects/production_readiness.py +145 -0
- dockerls/domain/value_objects/provenance.py +211 -0
- dockerls/domain/value_objects/recipe_diff.py +188 -0
- dockerls/domain/value_objects/registry_audit.py +195 -0
- dockerls/domain/value_objects/registry_target.py +225 -0
- dockerls/domain/value_objects/remediation_score.py +62 -0
- dockerls/domain/value_objects/scan_history.py +183 -0
- dockerls/domain/value_objects/scan_plan.py +193 -0
- dockerls/domain/value_objects/scanner_db.py +143 -0
- dockerls/domain/value_objects/security_score.py +160 -0
- dockerls/domain/value_objects/security_tier.py +122 -0
- dockerls/domain/value_objects/tag_history.py +180 -0
- dockerls/domain/value_objects/tool_release.py +253 -0
- dockerls/domain/value_objects/tristate.py +47 -0
- dockerls/domain/value_objects/vex.py +249 -0
- dockerls/exit_codes.py +23 -0
- dockerls/exporters/__init__.py +0 -0
- dockerls/exporters/base.py +17 -0
- dockerls/exporters/csv_exporter.py +85 -0
- dockerls/exporters/factory.py +31 -0
- dockerls/exporters/html_exporter.py +105 -0
- dockerls/exporters/json_exporter.py +19 -0
- dockerls/exporters/markdown_exporter.py +84 -0
- dockerls/exporters/sarif_exporter.py +245 -0
- dockerls/infrastructure/__init__.py +0 -0
- dockerls/infrastructure/config/__init__.py +0 -0
- dockerls/infrastructure/config/policy_file.py +165 -0
- dockerls/infrastructure/config/settings.py +197 -0
- dockerls/infrastructure/database/__init__.py +0 -0
- dockerls/infrastructure/database/models.py +78 -0
- dockerls/infrastructure/dockerfile_validator.py +1899 -0
- dockerls/infrastructure/evidence.py +99 -0
- dockerls/infrastructure/hashing.py +165 -0
- dockerls/infrastructure/logging/__init__.py +0 -0
- dockerls/infrastructure/logging/setup.py +98 -0
- dockerls/infrastructure/network/__init__.py +0 -0
- dockerls/infrastructure/network/guarded_client.py +107 -0
- dockerls/infrastructure/network/host_guard.py +117 -0
- dockerls/infrastructure/redaction.py +135 -0
- dockerls/infrastructure/templates/hardening/alpine.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/distroless.dockerfile +34 -0
- dockerls/infrastructure/templates/hardening/go-alpine.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/go-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/go-distroless.dockerfile +43 -0
- dockerls/infrastructure/templates/hardening/go-scratch.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/go.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/gradle-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/gradle.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/java-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-distroless.dockerfile +39 -0
- dockerls/infrastructure/templates/hardening/java-ubuntu.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/java.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/maven-alpine.dockerfile +56 -0
- dockerls/infrastructure/templates/hardening/maven.dockerfile +57 -0
- dockerls/infrastructure/templates/hardening/node-alpine.dockerfile +47 -0
- dockerls/infrastructure/templates/hardening/node-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/node-distroless.dockerfile +46 -0
- dockerls/infrastructure/templates/hardening/node-ubuntu.dockerfile +63 -0
- dockerls/infrastructure/templates/hardening/node.dockerfile +61 -0
- dockerls/infrastructure/templates/hardening/php-alpine.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-ubuntu.dockerfile +49 -0
- dockerls/infrastructure/templates/hardening/php.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/python-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/python-distroless.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-ubuntu.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/python.dockerfile +58 -0
- dockerls/infrastructure/templates/hardening/ruby-alpine.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/ruby-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-debian.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/rust-scratch.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/rust.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/ubuntu.dockerfile +49 -0
- dockerls/infrastructure/toolchain/__init__.py +0 -0
- dockerls/infrastructure/toolchain/db_metadata.py +115 -0
- dockerls/infrastructure/toolchain/installer.py +435 -0
- dockerls/integrations/__init__.py +0 -0
- dockerls/integrations/dhi/__init__.py +0 -0
- dockerls/integrations/dhi/catalog.py +457 -0
- dockerls/integrations/dhi/definition.py +151 -0
- dockerls/integrations/dhi/repository.py +238 -0
- dockerls/integrations/dockerhub/__init__.py +0 -0
- dockerls/integrations/dockerhub/client.py +318 -0
- dockerls/integrations/dockerhub/urls.py +75 -0
- dockerls/integrations/endoflife/__init__.py +0 -0
- dockerls/integrations/endoflife/checker.py +216 -0
- dockerls/integrations/engine/__init__.py +0 -0
- dockerls/integrations/engine/batch.py +197 -0
- dockerls/integrations/engine/client.py +330 -0
- dockerls/integrations/engine/locator.py +96 -0
- dockerls/integrations/exploitdb/__init__.py +0 -0
- dockerls/integrations/exploitdb/client.py +271 -0
- dockerls/integrations/grype/__init__.py +0 -0
- dockerls/integrations/grype/scanner.py +336 -0
- dockerls/integrations/registry/__init__.py +0 -0
- dockerls/integrations/registry/hardened.py +284 -0
- dockerls/integrations/registry/inspector.py +420 -0
- dockerls/integrations/registry/oci.py +259 -0
- dockerls/integrations/registry/private.py +79 -0
- dockerls/integrations/registry/urls.py +36 -0
- dockerls/integrations/scan_errors.py +67 -0
- dockerls/integrations/scan_target.py +57 -0
- dockerls/integrations/signing/__init__.py +0 -0
- dockerls/integrations/signing/cosign.py +484 -0
- dockerls/integrations/threat_intel/__init__.py +0 -0
- dockerls/integrations/threat_intel/client.py +290 -0
- dockerls/integrations/trivy/__init__.py +0 -0
- dockerls/integrations/trivy/cache_pool.py +176 -0
- dockerls/integrations/trivy/scanner.py +447 -0
- dockerls/utils/__init__.py +0 -0
- dockerls/utils/auth.py +108 -0
- dockerls/utils/executables.py +39 -0
- dockerls/utils/ignore_file.py +130 -0
- dockerls/utils/rate_limit.py +130 -0
- dockerls/utils/resources.py +183 -0
- dockerls/utils/retry.py +31 -0
- dockerls/utils/safe_yaml.py +166 -0
- dockerls/utils/subprocess_runner.py +216 -0
- dockerls/utils/validation.py +72 -0
- dockerls-1.0.0.dist-info/METADATA +563 -0
- dockerls-1.0.0.dist-info/RECORD +230 -0
- dockerls-1.0.0.dist-info/WHEEL +5 -0
- dockerls-1.0.0.dist-info/entry_points.txt +2 -0
- dockerls-1.0.0.dist-info/licenses/LICENSE +21 -0
- dockerls-1.0.0.dist-info/top_level.txt +1 -0
dockerls/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""DockerLs: Enterprise Docker Image Security Advisor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = ["__version__"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def __getattr__(name: str) -> Any:
|
|
11
|
+
"""Resolve `__version__` sob demanda.
|
|
12
|
+
|
|
13
|
+
`importlib.metadata` custa ~24ms para importar, e este `__init__` roda
|
|
14
|
+
antes de qualquer `dockerls.*` -- ou seja, todo comando da CLI pagava
|
|
15
|
+
esse preço, inclusive os que nunca mostram a versão. Com o
|
|
16
|
+
`__getattr__` de módulo (PEP 562) quem escreve
|
|
17
|
+
`from dockerls import __version__` continua funcionando igual, e quem
|
|
18
|
+
não escreve não paga.
|
|
19
|
+
"""
|
|
20
|
+
if name != "__version__":
|
|
21
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
22
|
+
|
|
23
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
resolved = version("dockerls")
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
# Editable/dev checkout without an installed distribution record.
|
|
29
|
+
resolved = "0.0.0+dev"
|
|
30
|
+
globals()["__version__"] = resolved
|
|
31
|
+
return resolved
|
|
File without changes
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from dockerls.domain.entities.image import DockerImage
|
|
6
|
+
from dockerls.domain.entities.image_facts import HardeningFacts
|
|
7
|
+
from dockerls.domain.entities.recommendation import Recommendation
|
|
8
|
+
from dockerls.domain.entities.scan_result import ScanResult
|
|
9
|
+
from dockerls.domain.entities.vulnerability import Vulnerability
|
|
10
|
+
from dockerls.domain.value_objects.confidence import Confidence
|
|
11
|
+
from dockerls.domain.value_objects.scan_plan import DeferredTag
|
|
12
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DimensionReport(BaseModel):
|
|
16
|
+
"""A derived score plus everything needed to defend it.
|
|
17
|
+
|
|
18
|
+
Both the hardening and attack-surface models are computed over the
|
|
19
|
+
facts that could be determined, so the number alone is not enough: a
|
|
20
|
+
reader needs the coverage it was computed at, and the named findings on
|
|
21
|
+
either side. Carrying them together means the terminal, the JSON output
|
|
22
|
+
and every exporter show the same defence of the same number.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
score: float = 0.0
|
|
26
|
+
#: Share of the model that could be determined, 0.0-1.0.
|
|
27
|
+
coverage: float = 0.0
|
|
28
|
+
#: False when coverage was too thin for the score to mean anything. The
|
|
29
|
+
#: renderers show "n/a" rather than a confident-looking number.
|
|
30
|
+
reportable: bool = False
|
|
31
|
+
#: Determined properties that counted in the image's favour.
|
|
32
|
+
positives: list[str] = Field(default_factory=list)
|
|
33
|
+
#: Determined properties that counted against it.
|
|
34
|
+
negatives: list[str] = Field(default_factory=list)
|
|
35
|
+
#: Properties nothing could establish, named rather than omitted.
|
|
36
|
+
undetermined: list[str] = Field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ImageAnalysis(BaseModel):
|
|
40
|
+
image: DockerImage
|
|
41
|
+
scan: ScanResult
|
|
42
|
+
security_score: float
|
|
43
|
+
tier: str
|
|
44
|
+
remediation_score: int
|
|
45
|
+
# The domain's verdict. Written **only** by the central
|
|
46
|
+
# ProductionReadiness policy (see application/services/verdict.py), never
|
|
47
|
+
# by the tier: the tier can see the score and nothing else, so it cannot
|
|
48
|
+
# tell a clean image from an image nobody managed to scan. Defaults to
|
|
49
|
+
# False so an analysis that never reached the policy is not ready by
|
|
50
|
+
# omission rather than ready by omission.
|
|
51
|
+
production_ready: bool = False
|
|
52
|
+
#: Stable codes for every rule the image failed (NOT_MEASURED,
|
|
53
|
+
#: END_OF_LIFE, ...), so a pipeline can branch without parsing prose.
|
|
54
|
+
readiness_blockers: list[str] = Field(default_factory=list)
|
|
55
|
+
#: The same blockers in the reader's terms.
|
|
56
|
+
readiness_reasons: list[str] = Field(default_factory=list)
|
|
57
|
+
# Kept as the boolean every exporter and template already reads. It
|
|
58
|
+
# answers False both for "supported" and for "nobody could tell", which
|
|
59
|
+
# is why `eol_status` carries the three-valued truth beside it.
|
|
60
|
+
is_eol: bool = False
|
|
61
|
+
#: TRUE / FALSE / UNKNOWN. An unknown lifecycle does not penalise the
|
|
62
|
+
#: score -- there is nothing to penalise -- but it is never spent as if
|
|
63
|
+
#: it were a confirmation that the release is still supported.
|
|
64
|
+
eol_status: Tristate = Tristate.UNKNOWN
|
|
65
|
+
is_lts: bool = False
|
|
66
|
+
recommendation: Recommendation | None = None
|
|
67
|
+
# Set when a second scanner disagreed materially with the primary one.
|
|
68
|
+
# A non-empty value means the score must be presented as disputed.
|
|
69
|
+
scan_divergence: str = ""
|
|
70
|
+
#: AGREEMENT / MINOR_DIVERGENCE / MATERIAL_DIVERGENCE / NO_SECOND_SCANNER.
|
|
71
|
+
#: Distinct from `scan_divergence`, which stays reserved for the material
|
|
72
|
+
#: case: two databases differing on a finding or two is ordinary, and
|
|
73
|
+
#: calling that "disputed" would make every image look contested.
|
|
74
|
+
cross_validation: str = "NO_SECOND_SCANNER"
|
|
75
|
+
#: Which findings differed, named, so the disagreement can be checked.
|
|
76
|
+
cross_validation_detail: str = ""
|
|
77
|
+
# Docker Hub linkage. `hub_tag_verified` is deliberately tri-state:
|
|
78
|
+
# True = confirmed present, False = confirmed absent, None = not checked
|
|
79
|
+
# (image not on Docker Hub, or verification unavailable).
|
|
80
|
+
hub_url: str = ""
|
|
81
|
+
hub_tag_verified: bool | None = None
|
|
82
|
+
# scanner name -> raw scan JSON path, backing the score shown above.
|
|
83
|
+
evidence_paths: dict[str, str] = Field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
# --- Multi-dimensional assessment ------------------------------------
|
|
86
|
+
# The evidence record every derived dimension below is computed from,
|
|
87
|
+
# carried so a consumer can recompute them or apply its own policy.
|
|
88
|
+
facts: HardeningFacts = Field(default_factory=HardeningFacts)
|
|
89
|
+
# How well the image is configured, independently of its CVE counts.
|
|
90
|
+
hardening: DimensionReport = Field(default_factory=DimensionReport)
|
|
91
|
+
# How much an attacker inherits inside the container. Higher is *worse*.
|
|
92
|
+
attack_surface: DimensionReport = Field(default_factory=DimensionReport)
|
|
93
|
+
# How much the evidence behind all of the above is worth. Defaults to
|
|
94
|
+
# UNVERIFIED so an analysis that skipped assessment can never read as
|
|
95
|
+
# trustworthy by omission.
|
|
96
|
+
confidence: Confidence = Confidence.UNVERIFIED
|
|
97
|
+
confidence_reasons: list[str] = Field(default_factory=list)
|
|
98
|
+
# Plain-language reasons this image ranked where it did, so a
|
|
99
|
+
# recommendation never reduces to an unexplained number.
|
|
100
|
+
why: list[str] = Field(default_factory=list)
|
|
101
|
+
# Costs and caveats of moving to this image, stated alongside the
|
|
102
|
+
# reasons. A recommendation that lists only upsides is advertising.
|
|
103
|
+
trade_offs: list[str] = Field(default_factory=list)
|
|
104
|
+
# Set when this tag has previously been observed (by an earlier run) on
|
|
105
|
+
# a *different* digest than the one just resolved. Empty when this is
|
|
106
|
+
# the first time this tag was seen, or when it has stayed on the same
|
|
107
|
+
# digest since. `base` already reports this for Dockerfile-pinned bases
|
|
108
|
+
# (see `tag_history.py`/`base_cmd.py`); this is the same fact for a tag
|
|
109
|
+
# looked up directly with `analyze`.
|
|
110
|
+
tag_drift_note: str = ""
|
|
111
|
+
# Set when a previous `analyze` run of this exact reference recorded
|
|
112
|
+
# different vulnerability counts than this scan just found. Empty on the
|
|
113
|
+
# first scan ever recorded, or when the counts are unchanged since the
|
|
114
|
+
# last one. Unlike `tag_drift_note`, this applies to a digest reference
|
|
115
|
+
# too: the same bytes can gain a CVE between two scans as the scanner's
|
|
116
|
+
# database learns about it.
|
|
117
|
+
vuln_trend_note: str = ""
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def pinned_reference(self) -> str:
|
|
121
|
+
"""What to actually deploy: digest-pinned when one was resolved."""
|
|
122
|
+
return self.image.pinned_reference
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class BaselineCriteria(BaseModel):
|
|
126
|
+
"""The exact thresholds an image had to clear to count as a match.
|
|
127
|
+
|
|
128
|
+
Carried on the result so "no image found matching baseline" can state
|
|
129
|
+
what the baseline actually was instead of leaving the user to guess.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
max_critical: int
|
|
133
|
+
max_high: int
|
|
134
|
+
max_medium: int
|
|
135
|
+
|
|
136
|
+
def describe(self) -> str:
|
|
137
|
+
return (
|
|
138
|
+
f"{self.max_critical} Critical, "
|
|
139
|
+
f"{self.max_high} High, "
|
|
140
|
+
f"{self.max_medium} Medium (and not EOL)"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class UnverifiedImage(BaseModel):
|
|
145
|
+
"""A tag that could not be scanned successfully.
|
|
146
|
+
|
|
147
|
+
These never carry a score or a tier -- an image with no proof of a
|
|
148
|
+
successful scan is reported as unverified, not ranked.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
image_reference: str
|
|
152
|
+
status: str
|
|
153
|
+
reason: str
|
|
154
|
+
# Causa classificada (DB_INIT_FAILED, TIMEOUT, NOT_FOUND, ...). O terminal
|
|
155
|
+
# mostra isto; `reason` guarda o stderr completo para log e --format json.
|
|
156
|
+
kind: str = "UNKNOWN"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class RunMetrics(BaseModel):
|
|
160
|
+
"""What the run actually did, as opposed to what it found.
|
|
161
|
+
|
|
162
|
+
The pipeline already knew every one of these numbers and discarded all
|
|
163
|
+
of them, so "why did that take four minutes" and "is the cache working"
|
|
164
|
+
were unanswerable from the outside. They are the difference between
|
|
165
|
+
tags *discovered* and scans *performed*, which the digest deduplication
|
|
166
|
+
and the cache can make very different.
|
|
167
|
+
|
|
168
|
+
Carried on the result rather than printed, so `--format json` and the
|
|
169
|
+
terminal report the same figures.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
tags_discovered: int = 0
|
|
173
|
+
# Tags left after collapsing those that share a manifest digest. The gap
|
|
174
|
+
# between this and `tags_discovered` is what deduplication saved.
|
|
175
|
+
unique_digests: int = 0
|
|
176
|
+
cache_hits: int = 0
|
|
177
|
+
# Scanner invocations actually made, excluding cache hits and duplicates.
|
|
178
|
+
scans_performed: int = 0
|
|
179
|
+
cross_validations: int = 0
|
|
180
|
+
workers: int = 0
|
|
181
|
+
# Tags that arrived without a digest and were pinned to one. Each is a
|
|
182
|
+
# registry HEAD that buys deduplication across every source.
|
|
183
|
+
digests_resolved: int = 0
|
|
184
|
+
# Candidates whose OCI config was fetched and verified, which is what
|
|
185
|
+
# makes their hardening facts measurements rather than claims.
|
|
186
|
+
images_inspected: int = 0
|
|
187
|
+
#: Which scanner, at which version, produced this run's measurements.
|
|
188
|
+
#: Reported rather than assumed: two runs of the same command against the
|
|
189
|
+
#: same image are only comparable if this matches.
|
|
190
|
+
scanner_identity: str = ""
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def duplicates_collapsed(self) -> int:
|
|
194
|
+
return max(0, self.tags_discovered - self.unique_digests)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def cache_hit_rate(self) -> float:
|
|
198
|
+
"""Share of candidates answered from cache, 0.0-1.0."""
|
|
199
|
+
considered = self.cache_hits + self.scans_performed
|
|
200
|
+
return self.cache_hits / considered if considered else 0.0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class AnalysisResult(BaseModel):
|
|
204
|
+
query: str
|
|
205
|
+
total_tags_scanned: int
|
|
206
|
+
baseline_met: bool
|
|
207
|
+
recommendations: list[ImageAnalysis] = []
|
|
208
|
+
alternatives: list[ImageAnalysis] = []
|
|
209
|
+
errors: list[str] = []
|
|
210
|
+
# Run accounting, used to render the summary line above the table.
|
|
211
|
+
total_tags_analyzed: int = 0
|
|
212
|
+
unverified: list[UnverifiedImage] = []
|
|
213
|
+
log_file: str = ""
|
|
214
|
+
evidence_manifest: str = ""
|
|
215
|
+
baseline: BaselineCriteria | None = None
|
|
216
|
+
# Catalogues that returned at least one candidate for this query.
|
|
217
|
+
sources_searched: list[str] = []
|
|
218
|
+
metrics: RunMetrics = Field(default_factory=RunMetrics)
|
|
219
|
+
#: Tags que a busca encontrou e que este run deliberadamente **não
|
|
220
|
+
#: mediu**, com o motivo de cada uma. Deliberadamente separado de
|
|
221
|
+
#: `unverified`: ali estão as medições que falharam, aqui as que nunca
|
|
222
|
+
#: foram tentadas. As duas são ausência de medição, e nenhuma das duas
|
|
223
|
+
#: é um veredito sobre a imagem -- mas confundi-las esconderia que uma
|
|
224
|
+
#: é escolha desta ferramenta e a outra é uma falha.
|
|
225
|
+
deferred: list[DeferredTag] = []
|
|
226
|
+
#: Quantas tags a busca trouxe, antes de qualquer corte.
|
|
227
|
+
tags_discovered: int = 0
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def unverified_count(self) -> int:
|
|
231
|
+
return len(self.unverified)
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def deferred_count(self) -> int:
|
|
235
|
+
return len(self.deferred)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class ComparisonResult(BaseModel):
|
|
239
|
+
"""O que a comparação mediu, e o que ela não conseguiu medir.
|
|
240
|
+
|
|
241
|
+
`images` carrega **apenas** as imagens cujo scan completou. Uma imagem
|
|
242
|
+
que ninguém conseguiu escanear não entra aqui em hipótese alguma: ela
|
|
243
|
+
tem `security_score` 0.0 e tier F por construção (o fallback de
|
|
244
|
+
`AnalyzeImageUseCase`), e uma linha na tabela de comparação com esses
|
|
245
|
+
valores afirma que a imagem foi medida e foi mal -- que é exatamente a
|
|
246
|
+
substituição que esta ferramenta existe para não fazer. As que
|
|
247
|
+
falharam ficam em `unverified`, com a causa classificada.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
images: list[ImageAnalysis]
|
|
251
|
+
winner: str = ""
|
|
252
|
+
summary: str = ""
|
|
253
|
+
common_vulns: list[Vulnerability] = []
|
|
254
|
+
unique_vulns: dict[str, list[Vulnerability]] = {}
|
|
255
|
+
#: As referências pedidas que não puderam ser medidas, na ordem em que
|
|
256
|
+
#: foram pedidas. Nunca recebem score nem tier.
|
|
257
|
+
unverified: list[UnverifiedImage] = []
|
|
File without changes
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Achar uma alternativa **medida** para uma imagem, e dizer o que ela custa.
|
|
2
|
+
|
|
3
|
+
O `alternatives` já respondia isto para uma referência digitada à mão. O que
|
|
4
|
+
faltava era ligar a resposta ao lugar onde a pergunta nasce: o `FROM` de um
|
|
5
|
+
Dockerfile. O `base` sabia dizer que uma base apodreceu e sabia atualizar o
|
|
6
|
+
digest -- e continuava propondo a mesma imagem, mais nova. Trocar `node:22`
|
|
7
|
+
por `node:22` de ontem resolve a data e não resolve a escolha.
|
|
8
|
+
|
|
9
|
+
Este serviço é a metade compartilhada entre os dois comandos, e existe para
|
|
10
|
+
que eles não divirjam: a mesma definição de "melhor", o mesmo baseline medido,
|
|
11
|
+
os mesmos trade-offs impressos ao lado dos ganhos.
|
|
12
|
+
|
|
13
|
+
Duas recusas o definem:
|
|
14
|
+
|
|
15
|
+
* **Sem baseline medido não há sugestão.** Se a imagem atual não pôde ser
|
|
16
|
+
escaneada, não há como afirmar que outra é melhor. O serviço devolve o
|
|
17
|
+
motivo, e quem chama reporta isso -- nunca uma alternativa apresentada
|
|
18
|
+
contra um baseline desconhecido.
|
|
19
|
+
* **Uma alternativa pior não é escondida.** O plano de migração carrega
|
|
20
|
+
`score_delta` negativo quando é o caso, e quem chama decide. Filtrar
|
|
21
|
+
silenciosamente o que ficou pior transformaria a lista num argumento em vez
|
|
22
|
+
de uma medição.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
from loguru import logger
|
|
31
|
+
|
|
32
|
+
from dockerls.application.services.migration import plan_migration
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from dockerls.application.dto.analysis import ImageAnalysis
|
|
36
|
+
from dockerls.application.services.migration import MigrationPlan
|
|
37
|
+
from dockerls.application.use_cases.analyze_image import AnalyzeImageUseCase
|
|
38
|
+
from dockerls.application.use_cases.recommend_images import RecommendImagesUseCase
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class AlternativeSuggestion:
|
|
43
|
+
"""A melhor alternativa medida para uma referência, com o custo da troca."""
|
|
44
|
+
|
|
45
|
+
reference: str
|
|
46
|
+
current: ImageAnalysis
|
|
47
|
+
candidate: ImageAnalysis
|
|
48
|
+
plan: MigrationPlan
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def improves(self) -> bool:
|
|
52
|
+
"""Se a troca melhora alguma coisa que foi medida.
|
|
53
|
+
|
|
54
|
+
Um `score_delta` positivo sozinho não basta: o que decide na prática é
|
|
55
|
+
CVE a menos, e um score melhor com mais CRITICAL seria uma melhora no
|
|
56
|
+
papel.
|
|
57
|
+
"""
|
|
58
|
+
return (
|
|
59
|
+
self.plan.critical_delta < 0
|
|
60
|
+
or self.plan.high_delta < 0
|
|
61
|
+
or (
|
|
62
|
+
self.plan.score_delta > 0
|
|
63
|
+
and self.plan.critical_delta <= 0
|
|
64
|
+
and self.plan.high_delta <= 0
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, object]:
|
|
69
|
+
return {
|
|
70
|
+
"for": self.reference,
|
|
71
|
+
"candidate": self.candidate.image.full_reference,
|
|
72
|
+
"pinned": self.plan.to_pinned_reference,
|
|
73
|
+
"improves": self.improves,
|
|
74
|
+
"score_delta": self.plan.score_delta,
|
|
75
|
+
"critical_delta": self.plan.critical_delta,
|
|
76
|
+
"high_delta": self.plan.high_delta,
|
|
77
|
+
"improvements": list(self.plan.improvements),
|
|
78
|
+
"trade_offs": list(self.plan.trade_offs),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class AlternativeFailure:
|
|
84
|
+
"""Por que não houve sugestão. Nunca confundido com "não há nada melhor"."""
|
|
85
|
+
|
|
86
|
+
reference: str
|
|
87
|
+
reason: str
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, str]:
|
|
90
|
+
return {"for": self.reference, "reason": self.reason}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def best_alternative(
|
|
94
|
+
reference: str,
|
|
95
|
+
*,
|
|
96
|
+
analyzer: AnalyzeImageUseCase,
|
|
97
|
+
recommender: RecommendImagesUseCase,
|
|
98
|
+
) -> AlternativeSuggestion | AlternativeFailure:
|
|
99
|
+
"""A melhor alternativa medida para `reference`, ou o motivo de não haver.
|
|
100
|
+
|
|
101
|
+
O tipo de retorno é a garantia que importa: quem chama é obrigado a
|
|
102
|
+
distinguir "não achamos nada melhor" de "não conseguimos medir", porque as
|
|
103
|
+
duas coisas chegam como valores diferentes em vez de como `None`.
|
|
104
|
+
"""
|
|
105
|
+
# Uma medição que não aconteceu nunca vira baseline. A frase é a mesma
|
|
106
|
+
# para as duas formas de falhar, porque significam a mesma coisa.
|
|
107
|
+
unmeasured = (
|
|
108
|
+
f"{reference} could not be scanned, so no improvement over it can be measured. "
|
|
109
|
+
"This is a technical failure, not a verdict about the image"
|
|
110
|
+
)
|
|
111
|
+
try:
|
|
112
|
+
current = await analyzer.execute(reference)
|
|
113
|
+
except (ValueError, RuntimeError) as e:
|
|
114
|
+
logger.debug(f"Could not analyze {reference}: {e}")
|
|
115
|
+
return AlternativeFailure(reference=reference, reason=unmeasured)
|
|
116
|
+
|
|
117
|
+
# A falha chega de duas formas e as duas valem o mesmo aqui. A exceção
|
|
118
|
+
# é o caso antigo; hoje um scan que não completou devolve um
|
|
119
|
+
# `ImageAnalysis` com score 0.0 e tier F por construção, e aceitá-lo
|
|
120
|
+
# como baseline faria toda candidata aparecer como uma melhora enorme
|
|
121
|
+
# sobre uma imagem que ninguém mediu -- exatamente a substituição que a
|
|
122
|
+
# primeira recusa deste módulo existe para impedir.
|
|
123
|
+
if not current.scan.is_verified:
|
|
124
|
+
return AlternativeFailure(reference=reference, reason=unmeasured)
|
|
125
|
+
|
|
126
|
+
repository = _repository_of(reference)
|
|
127
|
+
try:
|
|
128
|
+
result = await recommender.execute(repository)
|
|
129
|
+
except (ValueError, RuntimeError) as e:
|
|
130
|
+
logger.debug(f"Could not search for alternatives to {repository}: {e}")
|
|
131
|
+
return AlternativeFailure(
|
|
132
|
+
reference=reference,
|
|
133
|
+
reason=f"the search for alternatives to {repository} failed: {e}",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
for candidate in result.recommendations or result.alternatives:
|
|
137
|
+
if candidate.image.full_reference == current.image.full_reference:
|
|
138
|
+
continue
|
|
139
|
+
if not candidate.confidence.is_recommendable:
|
|
140
|
+
# Uma candidata que a própria ferramenta não consegue afirmar não
|
|
141
|
+
# entra: sugerir com pouca confiança é transferir a incerteza para
|
|
142
|
+
# quem vai fazer a migração sem dizer que ela existe.
|
|
143
|
+
continue
|
|
144
|
+
return AlternativeSuggestion(
|
|
145
|
+
reference=reference,
|
|
146
|
+
current=current,
|
|
147
|
+
candidate=candidate,
|
|
148
|
+
plan=plan_migration(current, candidate),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return AlternativeFailure(
|
|
152
|
+
reference=reference,
|
|
153
|
+
reason=(
|
|
154
|
+
f"no alternative to {repository} was measured with enough confidence to be recommended"
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _repository_of(reference: str) -> str:
|
|
160
|
+
"""A parte que a descoberta procura: `node:22@sha256:...` -> `node`."""
|
|
161
|
+
head = reference.split("@", 1)[0]
|
|
162
|
+
repository, separator, tail = head.rpartition(":")
|
|
163
|
+
# `registry:5000/app` tem `:` no host, não na tag: só é tag se o que vem
|
|
164
|
+
# depois não contiver barra.
|
|
165
|
+
if separator and "/" not in tail:
|
|
166
|
+
return repository
|
|
167
|
+
return head
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from dockerls.domain.entities.image import DockerImage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CompositeImageRepository(ImageRepositoryInterface):
|
|
15
|
+
"""Fans a query out across every configured image source.
|
|
16
|
+
|
|
17
|
+
The primary source (Docker Hub) sets the bulk of the candidate list;
|
|
18
|
+
hardened catalogues contribute a small number of tags each. All of them
|
|
19
|
+
feed the same scan pipeline, so a hardened image wins on measured
|
|
20
|
+
vulnerabilities rather than on reputation.
|
|
21
|
+
|
|
22
|
+
A source that fails is logged and skipped -- one unreachable registry
|
|
23
|
+
must not take down a search the other sources can still answer.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
primary: ImageRepositoryInterface,
|
|
29
|
+
extra: list[ImageRepositoryInterface] | None = None,
|
|
30
|
+
extra_limit: int = 10,
|
|
31
|
+
):
|
|
32
|
+
self._primary = primary
|
|
33
|
+
self._extra = extra or []
|
|
34
|
+
self._extra_limit = extra_limit
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def sources(self) -> list[ImageRepositoryInterface]:
|
|
38
|
+
return [self._primary, *self._extra]
|
|
39
|
+
|
|
40
|
+
async def search_tags(self, image_name: str, limit: int = 100) -> list[DockerImage]:
|
|
41
|
+
async def safe(repo: ImageRepositoryInterface, per_source_limit: int) -> list[DockerImage]:
|
|
42
|
+
try:
|
|
43
|
+
return await repo.search_tags(image_name, limit=per_source_limit)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.warning(f"{type(repo).__name__} search failed for {image_name}: {e}")
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
results = await asyncio.gather(
|
|
49
|
+
safe(self._primary, limit),
|
|
50
|
+
*[safe(repo, self._extra_limit) for repo in self._extra],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
merged: list[DockerImage] = []
|
|
54
|
+
seen: set[str] = set()
|
|
55
|
+
for source_tags in results:
|
|
56
|
+
for image in source_tags:
|
|
57
|
+
if image.full_reference in seen:
|
|
58
|
+
continue
|
|
59
|
+
seen.add(image.full_reference)
|
|
60
|
+
merged.append(image)
|
|
61
|
+
return merged
|
|
62
|
+
|
|
63
|
+
async def get_image_metadata(self, image_name: str, tag: str) -> DockerImage | None:
|
|
64
|
+
for repo in self.sources:
|
|
65
|
+
try:
|
|
66
|
+
found = await repo.get_image_metadata(image_name, tag)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.warning(f"{type(repo).__name__} metadata failed for {image_name}: {e}")
|
|
69
|
+
continue
|
|
70
|
+
if found is not None:
|
|
71
|
+
return found
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
async def tag_exists(self, image_name: str, tag: str) -> bool | None:
|
|
75
|
+
"""Route the check to the source that owns the reference.
|
|
76
|
+
|
|
77
|
+
`image_name` here is the fully-qualified name the pipeline scanned
|
|
78
|
+
(e.g. "cgr.dev/chainguard/node"), so the owning source is whichever
|
|
79
|
+
one recognises it.
|
|
80
|
+
"""
|
|
81
|
+
for repo in self.sources:
|
|
82
|
+
host = getattr(repo, "host", "")
|
|
83
|
+
if host and image_name.startswith(f"{host}/"):
|
|
84
|
+
return await _safe_tag_exists(repo, _strip_host(image_name, host), tag)
|
|
85
|
+
return await _safe_tag_exists(self._primary, image_name, tag)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _strip_host(image_name: str, host: str) -> str:
|
|
89
|
+
"""Turn "cgr.dev/chainguard/node" back into the bare query "node" the
|
|
90
|
+
source's own `repository_for()` expects."""
|
|
91
|
+
remainder = image_name[len(host) + 1 :]
|
|
92
|
+
return remainder.split("/", 1)[1] if "/" in remainder else remainder
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _safe_tag_exists(
|
|
96
|
+
repo: ImageRepositoryInterface, image_name: str, tag: str
|
|
97
|
+
) -> bool | None:
|
|
98
|
+
checker = getattr(repo, "tag_exists", None)
|
|
99
|
+
if not callable(checker):
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
result: bool | None = await checker(image_name, tag)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.warning(f"{type(repo).__name__} tag check failed for {image_name}:{tag}: {e}")
|
|
105
|
+
return None
|
|
106
|
+
return result
|