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,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from dockerls.domain.entities.vulnerability import Severity, finding_identity
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from dockerls.application.dto.analysis import ImageAnalysis
|
|
13
|
+
from dockerls.domain.entities.scan_result import ScanResult
|
|
14
|
+
from dockerls.domain.interfaces.scanner import ScannerInterface
|
|
15
|
+
|
|
16
|
+
# A second scanner never reproduces the first one's findings exactly -- the
|
|
17
|
+
# databases differ and each maps severities its own way. Only a difference
|
|
18
|
+
# that is both large in absolute terms *and* large relative to what was found
|
|
19
|
+
# is treated as a real disagreement worth flagging.
|
|
20
|
+
DEFAULT_ABS_TOLERANCE = 2
|
|
21
|
+
DEFAULT_REL_TOLERANCE = 0.5
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CrossValidationOutcome(StrEnum):
|
|
25
|
+
"""What the second opinion amounted to.
|
|
26
|
+
|
|
27
|
+
Kept as a named outcome rather than a boolean because the three
|
|
28
|
+
interesting states are not "agrees / disagrees" but "agrees",
|
|
29
|
+
"differs in ways two databases normally differ", and "tells a
|
|
30
|
+
different story" -- and the last of those must reach the confidence
|
|
31
|
+
model, while the middle one merely stops it reaching the top.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
NO_SECOND_SCANNER = "NO_SECOND_SCANNER"
|
|
35
|
+
AGREEMENT = "AGREEMENT"
|
|
36
|
+
MINOR_DIVERGENCE = "MINOR_DIVERGENCE"
|
|
37
|
+
MATERIAL_DIVERGENCE = "MATERIAL_DIVERGENCE"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
#: Severity bands compared by identity. LOW and MEDIUM are deliberately out:
|
|
41
|
+
#: their populations are large, the two databases classify them differently
|
|
42
|
+
#: as a matter of course, and comparing them would make every image look
|
|
43
|
+
#: disputed.
|
|
44
|
+
_COMPARED_SEVERITIES = (Severity.CRITICAL, Severity.HIGH)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Validations are independent of each other, so they run concurrently. The
|
|
48
|
+
# cap keeps a handful of scanner processes from thrashing the machine.
|
|
49
|
+
DEFAULT_WORKERS = 5
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
#: Worst-first, so `_worse` needs no comparison table of its own.
|
|
53
|
+
_SEVERITY_OF_OUTCOME = {
|
|
54
|
+
CrossValidationOutcome.MATERIAL_DIVERGENCE: 3,
|
|
55
|
+
CrossValidationOutcome.MINOR_DIVERGENCE: 2,
|
|
56
|
+
CrossValidationOutcome.AGREEMENT: 1,
|
|
57
|
+
CrossValidationOutcome.NO_SECOND_SCANNER: 0,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _worse(a: CrossValidationOutcome, b: CrossValidationOutcome) -> CrossValidationOutcome:
|
|
62
|
+
return a if _SEVERITY_OF_OUTCOME[a] >= _SEVERITY_OF_OUTCOME[b] else b
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
#: How many differing findings to name before the message stops being
|
|
66
|
+
#: readable. The full picture is in the raw evidence of both scanners.
|
|
67
|
+
_MAX_EXAMPLES = 3
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _examples(identities: set[str]) -> str:
|
|
71
|
+
"""Name a few of the disputed findings, so the reader can go and look."""
|
|
72
|
+
if not identities:
|
|
73
|
+
return ""
|
|
74
|
+
shown = sorted(identities)[:_MAX_EXAMPLES]
|
|
75
|
+
names = ", ".join(identity.split("|", 1)[0] for identity in shown)
|
|
76
|
+
more = len(identities) - len(shown)
|
|
77
|
+
return f" [{names}{f', +{more} more' if more > 0 else ''}]"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CrossValidator:
|
|
81
|
+
"""Re-scans top candidates with a second scanner and flags material
|
|
82
|
+
disagreements, so a score is never presented at full confidence when
|
|
83
|
+
two independent scanners tell different stories."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
scanner: ScannerInterface | None,
|
|
88
|
+
abs_tolerance: int = DEFAULT_ABS_TOLERANCE,
|
|
89
|
+
rel_tolerance: float = DEFAULT_REL_TOLERANCE,
|
|
90
|
+
workers: int = DEFAULT_WORKERS,
|
|
91
|
+
):
|
|
92
|
+
self._scanner = scanner
|
|
93
|
+
self._abs_tolerance = abs_tolerance
|
|
94
|
+
self._rel_tolerance = rel_tolerance
|
|
95
|
+
self._workers = max(1, workers)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def enabled(self) -> bool:
|
|
99
|
+
return self._scanner is not None
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def scanner(self) -> ScannerInterface | None:
|
|
103
|
+
return self._scanner
|
|
104
|
+
|
|
105
|
+
async def validate(self, analyses: list[ImageAnalysis]) -> None:
|
|
106
|
+
"""Annotate each analysis in place with `scan_divergence` and the
|
|
107
|
+
secondary scanner's evidence path.
|
|
108
|
+
|
|
109
|
+
The DB is refreshed once before the batch, then the validations run
|
|
110
|
+
concurrently -- they share no state, so serializing them only added
|
|
111
|
+
latency.
|
|
112
|
+
"""
|
|
113
|
+
if self._scanner is None or not analyses:
|
|
114
|
+
return
|
|
115
|
+
if not await self._scanner.is_available():
|
|
116
|
+
logger.info("Cross-validation scanner unavailable; skipping")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
refresh_db = getattr(self._scanner, "refresh_db", None)
|
|
120
|
+
if callable(refresh_db):
|
|
121
|
+
await refresh_db()
|
|
122
|
+
|
|
123
|
+
prefetched = await self._prescan(analyses)
|
|
124
|
+
|
|
125
|
+
semaphore = asyncio.Semaphore(self._workers)
|
|
126
|
+
|
|
127
|
+
async def guarded(analysis: ImageAnalysis) -> None:
|
|
128
|
+
async with semaphore:
|
|
129
|
+
await self._validate_one(analysis, prefetched)
|
|
130
|
+
|
|
131
|
+
await asyncio.gather(*[guarded(a) for a in analyses])
|
|
132
|
+
|
|
133
|
+
async def _prescan(self, analyses: list[ImageAnalysis]) -> dict[str, ScanResult]:
|
|
134
|
+
"""Mede os finalistas de uma vez, quando a engine Go existe.
|
|
135
|
+
|
|
136
|
+
São poucos scans -- os finalistas, não as cem candidatas --, então o
|
|
137
|
+
ganho aqui é menor que no passo principal. Vale mesmo assim: é o
|
|
138
|
+
mesmo caminho, e mantê-lo fora criaria uma segunda forma de
|
|
139
|
+
orquestrar scans para o projeto manter.
|
|
140
|
+
|
|
141
|
+
`{}` quando não há engine, e o caminho de sempre responde.
|
|
142
|
+
"""
|
|
143
|
+
batch = getattr(self._scanner, "batch", None)
|
|
144
|
+
if batch is None:
|
|
145
|
+
return {}
|
|
146
|
+
# A chave é a referência: o dedup por digest não se aplica aqui,
|
|
147
|
+
# porque os finalistas já vieram deduplicados do passo principal.
|
|
148
|
+
targets = [(a.image.full_reference, a.image.digest or "") for a in analyses]
|
|
149
|
+
outcome = await batch.scan_batch(targets)
|
|
150
|
+
if outcome is None:
|
|
151
|
+
return {}
|
|
152
|
+
return {
|
|
153
|
+
reference: result
|
|
154
|
+
for (reference, _), result in zip(targets, outcome.results, strict=True)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async def _validate_one(
|
|
158
|
+
self, analysis: ImageAnalysis, prefetched: dict[str, ScanResult] | None = None
|
|
159
|
+
) -> None:
|
|
160
|
+
if self._scanner is None:
|
|
161
|
+
return
|
|
162
|
+
reference = analysis.image.full_reference
|
|
163
|
+
secondary = (prefetched or {}).get(reference)
|
|
164
|
+
if secondary is None:
|
|
165
|
+
secondary = await self._scanner.scan(reference)
|
|
166
|
+
|
|
167
|
+
if not secondary.is_verified:
|
|
168
|
+
logger.warning(
|
|
169
|
+
f"Cross-validation of {reference} did not complete "
|
|
170
|
+
f"({secondary.status.value}: {secondary.error_message or 'no details'})"
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if secondary.evidence_path:
|
|
175
|
+
analysis.evidence_paths[secondary.scanner] = secondary.evidence_path
|
|
176
|
+
|
|
177
|
+
outcome, description = self.compare(analysis.scan, secondary)
|
|
178
|
+
analysis.cross_validation = outcome.value
|
|
179
|
+
analysis.cross_validation_detail = description
|
|
180
|
+
if outcome is CrossValidationOutcome.MATERIAL_DIVERGENCE:
|
|
181
|
+
logger.warning(f"Material scanner divergence for {reference}: {description}")
|
|
182
|
+
# `scan_divergence` remains the field the table, the exporters
|
|
183
|
+
# and the confidence model already read, and it stays reserved
|
|
184
|
+
# for material disagreement -- a minor one is recorded in
|
|
185
|
+
# `cross_validation` without disputing the score.
|
|
186
|
+
analysis.scan_divergence = description
|
|
187
|
+
elif outcome is CrossValidationOutcome.MINOR_DIVERGENCE:
|
|
188
|
+
logger.info(f"Minor scanner divergence for {reference}: {description}")
|
|
189
|
+
|
|
190
|
+
def compare(
|
|
191
|
+
self, primary: ScanResult, secondary: ScanResult
|
|
192
|
+
) -> tuple[CrossValidationOutcome, str]:
|
|
193
|
+
"""Classify two scans of the same image by *which* findings differ.
|
|
194
|
+
|
|
195
|
+
Comparing counts alone accepted a case it should not: two scanners
|
|
196
|
+
each reporting one CRITICAL, for two entirely different CVEs, agreed
|
|
197
|
+
perfectly on the arithmetic while describing different images. What
|
|
198
|
+
is compared here is the set of findings, so that case reads as the
|
|
199
|
+
divergence it is.
|
|
200
|
+
"""
|
|
201
|
+
parts: list[str] = []
|
|
202
|
+
worst = CrossValidationOutcome.AGREEMENT
|
|
203
|
+
|
|
204
|
+
for severity in _COMPARED_SEVERITIES:
|
|
205
|
+
mine = {finding_identity(v) for v in primary.vulnerabilities if v.severity is severity}
|
|
206
|
+
theirs = {
|
|
207
|
+
finding_identity(v) for v in secondary.vulnerabilities if v.severity is severity
|
|
208
|
+
}
|
|
209
|
+
only_mine = mine - theirs
|
|
210
|
+
only_theirs = theirs - mine
|
|
211
|
+
if not only_mine and not only_theirs:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
magnitude = len(only_mine) + len(only_theirs)
|
|
215
|
+
baseline = max(len(mine), len(theirs), 1)
|
|
216
|
+
material = (
|
|
217
|
+
magnitude > self._abs_tolerance and (magnitude / baseline) > self._rel_tolerance
|
|
218
|
+
)
|
|
219
|
+
worst = _worse(
|
|
220
|
+
worst,
|
|
221
|
+
CrossValidationOutcome.MATERIAL_DIVERGENCE
|
|
222
|
+
if material
|
|
223
|
+
else CrossValidationOutcome.MINOR_DIVERGENCE,
|
|
224
|
+
)
|
|
225
|
+
parts.append(
|
|
226
|
+
f"{severity.value}: {primary.scanner} found {len(mine)} "
|
|
227
|
+
f"({len(only_mine)} not seen by {secondary.scanner}), "
|
|
228
|
+
f"{secondary.scanner} found {len(theirs)} "
|
|
229
|
+
f"({len(only_theirs)} not seen by {primary.scanner})"
|
|
230
|
+
+ _examples(only_mine | only_theirs)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return worst, "; ".join(parts)
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Conhecimento especializado de ecossistemas, runtimes e particularidades de segurança."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class EcosystemInsight:
|
|
11
|
+
ecosystem: str
|
|
12
|
+
version: str
|
|
13
|
+
runtime_features: list[str] = field(default_factory=list)
|
|
14
|
+
base_distro_advice: list[str] = field(default_factory=list)
|
|
15
|
+
security_guidelines: list[str] = field(default_factory=list)
|
|
16
|
+
common_pitfalls: list[str] = field(default_factory=list)
|
|
17
|
+
recommended_dockerfile_snippets: list[str] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
#: Nome exato do repositório -> ecossistema. Consultado antes das palavras
|
|
21
|
+
#: soltas porque um nome exato não erra: `mongo` não é Go, e `maven` é Java
|
|
22
|
+
#: mesmo sem a palavra "java" aparecer em lugar nenhum.
|
|
23
|
+
_ECOSYSTEM_BY_NAME: dict[str, str] = {
|
|
24
|
+
"node": "node",
|
|
25
|
+
"nodejs": "node",
|
|
26
|
+
"bun": "node",
|
|
27
|
+
"deno": "node",
|
|
28
|
+
"python": "python",
|
|
29
|
+
"python3": "python",
|
|
30
|
+
"pypy": "python",
|
|
31
|
+
"go": "go",
|
|
32
|
+
"golang": "go",
|
|
33
|
+
"rust": "rust",
|
|
34
|
+
# Ferramentas de build são o ecossistema que constroem: quem roda `maven`
|
|
35
|
+
# está num projeto Java, e a alternativa endurecida que interessa é a de
|
|
36
|
+
# Java. Sem esta linha, `maven` caía em "generic" e não recebia conselho
|
|
37
|
+
# nenhum -- e ferramenta de build é exatamente onde um projeto de verdade
|
|
38
|
+
# começa o Dockerfile.
|
|
39
|
+
"maven": "java",
|
|
40
|
+
"gradle": "java",
|
|
41
|
+
"ant": "java",
|
|
42
|
+
"sbt": "java",
|
|
43
|
+
"tomcat": "java",
|
|
44
|
+
"jetty": "java",
|
|
45
|
+
"jdk": "java",
|
|
46
|
+
"jre": "java",
|
|
47
|
+
"openjdk": "java",
|
|
48
|
+
"temurin": "java",
|
|
49
|
+
"eclipse-temurin": "java",
|
|
50
|
+
"corretto": "java",
|
|
51
|
+
"amazoncorretto": "java",
|
|
52
|
+
"php": "php",
|
|
53
|
+
"composer": "php",
|
|
54
|
+
"ruby": "ruby",
|
|
55
|
+
"jruby": "ruby",
|
|
56
|
+
"dotnet": "dotnet",
|
|
57
|
+
"aspnet": "dotnet",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#: Fragmentos, para nomes compostos que nenhuma tabela cobre inteiramente
|
|
61
|
+
#: (`nodejs22-debian12`, `python3-debian12`). A ordem importa: o primeiro que
|
|
62
|
+
#: casar vence, e os mais específicos vêm antes.
|
|
63
|
+
_ECOSYSTEM_KEYWORDS: tuple[tuple[str, str], ...] = (
|
|
64
|
+
("nodejs", "node"),
|
|
65
|
+
("node", "node"),
|
|
66
|
+
("python", "python"),
|
|
67
|
+
("golang", "go"),
|
|
68
|
+
("rust", "rust"),
|
|
69
|
+
("temurin", "java"),
|
|
70
|
+
("openjdk", "java"),
|
|
71
|
+
("corretto", "java"),
|
|
72
|
+
("maven", "java"),
|
|
73
|
+
("gradle", "java"),
|
|
74
|
+
("java", "java"),
|
|
75
|
+
("aspnet", "dotnet"),
|
|
76
|
+
("dotnet", "dotnet"),
|
|
77
|
+
("php", "php"),
|
|
78
|
+
("ruby", "ruby"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def detect_ecosystem_and_version(image_reference: str) -> tuple[str, str, str]:
|
|
83
|
+
"""Detecta ecossistema (node, python, go, etc.), versão e distribuição base."""
|
|
84
|
+
ref_lower = image_reference.lower()
|
|
85
|
+
# O nome do repositório, sem registry e sem tag. Casar contra a referência
|
|
86
|
+
# inteira lia a tag e o host: `cgr.dev/chainguard/go:latest` não era
|
|
87
|
+
# reconhecido como Go (o "go" estava no caminho, não na tag), enquanto
|
|
88
|
+
# qualquer tag contendo "go" classificava a imagem errada.
|
|
89
|
+
repository = ref_lower.split("@", 1)[0].split(":", 1)[0].rstrip("/")
|
|
90
|
+
basename = repository.rsplit("/", 1)[-1]
|
|
91
|
+
|
|
92
|
+
# 1. Ecossistema
|
|
93
|
+
ecosystem = _ECOSYSTEM_BY_NAME.get(basename, "")
|
|
94
|
+
if not ecosystem:
|
|
95
|
+
for keyword, named in _ECOSYSTEM_KEYWORDS:
|
|
96
|
+
if keyword in basename:
|
|
97
|
+
ecosystem = named
|
|
98
|
+
break
|
|
99
|
+
ecosystem = ecosystem or "generic"
|
|
100
|
+
|
|
101
|
+
# 2. Versão
|
|
102
|
+
version = ""
|
|
103
|
+
tag_part = ref_lower.split(":")[-1] if ":" in ref_lower else ref_lower
|
|
104
|
+
v_match = re.search(r"(\d+(?:\.\d+)*)", tag_part)
|
|
105
|
+
if v_match:
|
|
106
|
+
version = v_match.group(1)
|
|
107
|
+
|
|
108
|
+
# 3. Distro base
|
|
109
|
+
distro = "debian/ubuntu"
|
|
110
|
+
if "alpine" in ref_lower:
|
|
111
|
+
distro = "alpine"
|
|
112
|
+
elif "distroless" in ref_lower:
|
|
113
|
+
distro = "distroless"
|
|
114
|
+
elif "wolfi" in ref_lower or "chainguard" in ref_lower:
|
|
115
|
+
distro = "wolfi/chainguard"
|
|
116
|
+
elif "scratch" in ref_lower:
|
|
117
|
+
distro = "scratch"
|
|
118
|
+
elif "slim" in ref_lower:
|
|
119
|
+
distro = "debian-slim"
|
|
120
|
+
|
|
121
|
+
return ecosystem, version, distro
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_ecosystem_insights(image_reference: str) -> EcosystemInsight:
|
|
125
|
+
"""Gera insights técnicos e de segurança detalhados para a imagem e versão."""
|
|
126
|
+
ecosystem, version, distro = detect_ecosystem_and_version(image_reference)
|
|
127
|
+
|
|
128
|
+
if ecosystem == "node":
|
|
129
|
+
major = version.split(".")[0] if version else "22"
|
|
130
|
+
runtime_features = [
|
|
131
|
+
f"Node.js {major}.x V8 engine with optimised ECMAScript Modules (ESM) support.",
|
|
132
|
+
"Native environment-file support (--env-file=.env), with no need for dotenv.",
|
|
133
|
+
"Native WebSocket client and native fetch API (Undici).",
|
|
134
|
+
"Native Corepack support for managing yarn/pnpm.",
|
|
135
|
+
]
|
|
136
|
+
base_advice = []
|
|
137
|
+
if distro == "alpine":
|
|
138
|
+
base_advice.extend(
|
|
139
|
+
[
|
|
140
|
+
"Alpine uses musl libc: native C++ packages (sharp, bcrypt, sqlite3) "
|
|
141
|
+
"must be compiled, or need 'libc6-compat'.",
|
|
142
|
+
"For full compatibility without the build overhead, consider "
|
|
143
|
+
"'node:22-bookworm-slim' (glibc).",
|
|
144
|
+
"Official 'node:alpine' images already ship the non-root user "
|
|
145
|
+
"'node' (UID 1000, GID 1000).",
|
|
146
|
+
]
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
base_advice.extend(
|
|
150
|
+
[
|
|
151
|
+
"Debian Slim is fully compatible with pre-built glibc binaries.",
|
|
152
|
+
"Consider 'distroless/nodejs22-debian12' to drop the shell.",
|
|
153
|
+
]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
security = [
|
|
157
|
+
"Set 'ENV NODE_ENV=production' to enable runtime optimisations and "
|
|
158
|
+
"disable devDependencies.",
|
|
159
|
+
"The bundled npm CLI has its own CVE cycle: run "
|
|
160
|
+
"'RUN npm install -g npm@latest', or drop it in a multi-stage build.",
|
|
161
|
+
"Tune '--max-old-space-size' so Node stays within the container memory limit.",
|
|
162
|
+
"Use 'USER node' (or create UID 10001) so the process never runs as root.",
|
|
163
|
+
]
|
|
164
|
+
pitfalls = [
|
|
165
|
+
"Avoid running 'npm start' as PID 1 (npm does not forward SIGTERM); "
|
|
166
|
+
'use \'CMD ["node", "dist/index.js"]\'.',
|
|
167
|
+
"Do not leave 'node_modules' in the build context root without a .dockerignore.",
|
|
168
|
+
]
|
|
169
|
+
snippets = [
|
|
170
|
+
'ENV NODE_ENV=production\nUSER node\nCMD ["--enable-source-maps", "dist/index.js"]',
|
|
171
|
+
'HEALTHCHECK --interval=30s --timeout=5s CMD node -e "'
|
|
172
|
+
"require('http').get('http://localhost:3000/health', (r) => {"
|
|
173
|
+
"if (r.statusCode !== 200) process.exit(1)"
|
|
174
|
+
"}).on('error', () => process.exit(1))\"",
|
|
175
|
+
]
|
|
176
|
+
return EcosystemInsight(
|
|
177
|
+
ecosystem="Node.js",
|
|
178
|
+
version=version or "22.x",
|
|
179
|
+
runtime_features=runtime_features,
|
|
180
|
+
base_distro_advice=base_advice,
|
|
181
|
+
security_guidelines=security,
|
|
182
|
+
common_pitfalls=pitfalls,
|
|
183
|
+
recommended_dockerfile_snippets=snippets,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
elif ecosystem == "python":
|
|
187
|
+
runtime_features = [
|
|
188
|
+
"Python runtime with dependency isolation through a multi-stage builder.",
|
|
189
|
+
"Supports Python 3.11/3.12/3.13, with execution-speed improvements.",
|
|
190
|
+
]
|
|
191
|
+
base_advice = []
|
|
192
|
+
if distro == "alpine":
|
|
193
|
+
base_advice.extend(
|
|
194
|
+
[
|
|
195
|
+
"Alpine musl does not support manylinux wheels. Libraries such as "
|
|
196
|
+
"pandas, numpy and cryptography compile from source (slow, needs gcc).",
|
|
197
|
+
"For Python with C/C++ dependencies, 'python:3.12-slim-bookworm' "
|
|
198
|
+
"builds far faster and produces smaller images.",
|
|
199
|
+
]
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
base_advice.extend(
|
|
203
|
+
[
|
|
204
|
+
"Debian Slim supports every pre-built manylinux wheel on PyPI, "
|
|
205
|
+
"so the final container needs no compilers.",
|
|
206
|
+
]
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
security = [
|
|
210
|
+
"Set 'ENV PYTHONUNBUFFERED=1' for unbuffered, real-time logs.",
|
|
211
|
+
"Set 'ENV PYTHONDONTWRITEBYTECODE=1' so no .pyc files are written.",
|
|
212
|
+
"Install dependencies with "
|
|
213
|
+
"'pip install --no-cache-dir --user -r requirements.txt' in the builder, "
|
|
214
|
+
"then copy '/root/.local' across for the non-root user.",
|
|
215
|
+
"Create an 'appuser' (UID 10001) to run the process.",
|
|
216
|
+
]
|
|
217
|
+
pitfalls = [
|
|
218
|
+
"Do not write healthchecks that depend on the external 'requests' "
|
|
219
|
+
"package; use 'urllib.request.urlopen' from the standard library.",
|
|
220
|
+
]
|
|
221
|
+
snippets = [
|
|
222
|
+
(
|
|
223
|
+
"ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1\n"
|
|
224
|
+
'USER appuser\nCMD ["python", "-u", "main.py"]'
|
|
225
|
+
),
|
|
226
|
+
(
|
|
227
|
+
'HEALTHCHECK --interval=30s --timeout=5s CMD python -c "'
|
|
228
|
+
"import urllib.request; "
|
|
229
|
+
"urllib.request.urlopen('http://localhost:8000/health', timeout=3)\" || exit 1"
|
|
230
|
+
),
|
|
231
|
+
]
|
|
232
|
+
return EcosystemInsight(
|
|
233
|
+
ecosystem="Python",
|
|
234
|
+
version=version or "3.12.x",
|
|
235
|
+
runtime_features=runtime_features,
|
|
236
|
+
base_distro_advice=base_advice,
|
|
237
|
+
security_guidelines=security,
|
|
238
|
+
common_pitfalls=pitfalls,
|
|
239
|
+
recommended_dockerfile_snippets=snippets,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
elif ecosystem == "go":
|
|
243
|
+
return EcosystemInsight(
|
|
244
|
+
ecosystem="Go",
|
|
245
|
+
version=version or "1.23.x",
|
|
246
|
+
runtime_features=[
|
|
247
|
+
"Statically linked native binaries, with no interpreter or runtime dependency.",
|
|
248
|
+
],
|
|
249
|
+
base_distro_advice=[
|
|
250
|
+
"'scratch' or distroless images give the smallest surface (zero CVEs).",
|
|
251
|
+
"Copy '/etc/ssl/certs/ca-certificates.crt' from the builder for HTTPS calls.",
|
|
252
|
+
],
|
|
253
|
+
security_guidelines=[
|
|
254
|
+
"Build with 'CGO_ENABLED=0 GOOS=linux go build -ldflags=\"-s -w\" -o app .'.",
|
|
255
|
+
"On 'scratch', use 'USER 65534:65534' (nobody): there is no /etc/passwd.",
|
|
256
|
+
],
|
|
257
|
+
common_pitfalls=[
|
|
258
|
+
"Do not use shell-form healthchecks on scratch; "
|
|
259
|
+
'use exec form: CMD ["/app", "-health"].',
|
|
260
|
+
],
|
|
261
|
+
recommended_dockerfile_snippets=[
|
|
262
|
+
"FROM scratch\n"
|
|
263
|
+
"COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/\n"
|
|
264
|
+
'COPY --from=builder /app/app /app\nUSER 65534:65534\nENTRYPOINT ["/app"]',
|
|
265
|
+
],
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
elif ecosystem == "java":
|
|
269
|
+
return EcosystemInsight(
|
|
270
|
+
ecosystem="Java / JVM",
|
|
271
|
+
version=version or "21 LTS",
|
|
272
|
+
runtime_features=[
|
|
273
|
+
"Eclipse Temurin / Amazon Corretto JRE with container awareness.",
|
|
274
|
+
],
|
|
275
|
+
base_distro_advice=[
|
|
276
|
+
"Use 'eclipse-temurin:21-jre-alpine' rather than the full JDK: it "
|
|
277
|
+
"drops over 300MB of tooling the runtime does not need.",
|
|
278
|
+
],
|
|
279
|
+
security_guidelines=[
|
|
280
|
+
"Set 'JAVA_OPTS=\"-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 "
|
|
281
|
+
"-Djava.security.egd=file:/dev/./urandom\"'.",
|
|
282
|
+
"Run as the non-root user 'appuser' (UID 10001).",
|
|
283
|
+
],
|
|
284
|
+
common_pitfalls=[
|
|
285
|
+
"Avoid pinning heap size (-Xmx) without accounting for the container memory limit.",
|
|
286
|
+
],
|
|
287
|
+
recommended_dockerfile_snippets=[
|
|
288
|
+
'ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"\n'
|
|
289
|
+
"USER appuser\n"
|
|
290
|
+
'ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]',
|
|
291
|
+
],
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
elif ecosystem == "rust":
|
|
295
|
+
return EcosystemInsight(
|
|
296
|
+
ecosystem="Rust",
|
|
297
|
+
version=version or "1.82",
|
|
298
|
+
runtime_features=[
|
|
299
|
+
"Statically linked native binaries with musl and target-feature=+crt-static.",
|
|
300
|
+
],
|
|
301
|
+
base_distro_advice=[
|
|
302
|
+
"'scratch' or distroless images reduce CVEs to zero.",
|
|
303
|
+
],
|
|
304
|
+
security_guidelines=[
|
|
305
|
+
"Use 'cargo build --release --target x86_64-unknown-linux-musl'.",
|
|
306
|
+
"Run as non-root: 'USER 65534:65534'.",
|
|
307
|
+
],
|
|
308
|
+
common_pitfalls=[],
|
|
309
|
+
recommended_dockerfile_snippets=[
|
|
310
|
+
(
|
|
311
|
+
"FROM scratch\n"
|
|
312
|
+
"COPY --from=builder /app/binary /app\n"
|
|
313
|
+
'USER 65534:65534\nENTRYPOINT ["/app"]'
|
|
314
|
+
),
|
|
315
|
+
],
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
elif ecosystem == "php":
|
|
319
|
+
return EcosystemInsight(
|
|
320
|
+
ecosystem="PHP",
|
|
321
|
+
version=version or "8.3",
|
|
322
|
+
runtime_features=[
|
|
323
|
+
"PHP 8.3 with JIT and Opcache enabled.",
|
|
324
|
+
],
|
|
325
|
+
base_distro_advice=[
|
|
326
|
+
"Use a multi-stage build with 'composer:2' in the builder, copying "
|
|
327
|
+
"only '/app/vendor'.",
|
|
328
|
+
],
|
|
329
|
+
security_guidelines=[
|
|
330
|
+
"Enable Opcache for performance, and run as non-root (UID 10001).",
|
|
331
|
+
],
|
|
332
|
+
common_pitfalls=[],
|
|
333
|
+
recommended_dockerfile_snippets=[
|
|
334
|
+
'USER appuser\nCMD ["php", "-S", "0.0.0.0:8000", "-t", "public"]',
|
|
335
|
+
],
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return EcosystemInsight(
|
|
339
|
+
ecosystem="Generic Container",
|
|
340
|
+
version=version or "latest",
|
|
341
|
+
runtime_features=["Standard Linux container."],
|
|
342
|
+
base_distro_advice=["Prefer minimal distributions such as Alpine or Distroless."],
|
|
343
|
+
security_guidelines=[
|
|
344
|
+
"Never run as root (USER 10001).",
|
|
345
|
+
"Keep a tidy .dockerignore.",
|
|
346
|
+
"Use healthchecks for liveness/readiness monitoring.",
|
|
347
|
+
],
|
|
348
|
+
common_pitfalls=[],
|
|
349
|
+
recommended_dockerfile_snippets=[],
|
|
350
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Um scanner que tenta o secundário quando o primário falha por culpa própria.
|
|
2
|
+
|
|
3
|
+
O fallback para o Grype existia só na *escolha* do scanner: `ScannerFactory`
|
|
4
|
+
olhava `is_available()` -- que é `shutil.which(...)` -- e, se o binário do
|
|
5
|
+
Trivy estivesse no PATH, o Grype nunca mais entrava na conversa. Um Trivy
|
|
6
|
+
instalado porém quebrado (DB corrompida, sem rede para baixá-la, timeout) não
|
|
7
|
+
acionava fallback nenhum: as tags simplesmente eram marcadas como não
|
|
8
|
+
verificadas, uma por uma.
|
|
9
|
+
|
|
10
|
+
Aqui o fallback passa a ser por *scan*, e não por processo. A condição de
|
|
11
|
+
acionamento é o `ScanErrorKind`: erro de DB, timeout, saída inválida e
|
|
12
|
+
rate limit são falhas do scanner, e o outro tem chance real de responder.
|
|
13
|
+
`NOT_FOUND` e `AUTH_REQUIRED` são fatos sobre a imagem -- perguntar de novo,
|
|
14
|
+
a outra ferramenta, só dobra a espera pela mesma resposta.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from loguru import logger
|
|
22
|
+
|
|
23
|
+
from dockerls.domain.interfaces.scanner import ScannerInterface
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from dockerls.domain.entities.scan_result import ScanResult
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FallbackScanner(ScannerInterface):
|
|
30
|
+
"""Delegates to `primary`, retrying with `secondary` on scanner-side faults."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, primary: ScannerInterface, secondary: ScannerInterface):
|
|
33
|
+
self._primary = primary
|
|
34
|
+
self._secondary = secondary
|
|
35
|
+
# Contabilidade para o resumo da execução: quantas vezes o secundário
|
|
36
|
+
# salvou um alvo que o primário não conseguiu medir.
|
|
37
|
+
self.fallback_successes = 0
|
|
38
|
+
self.fallback_attempts = 0
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def primary(self) -> ScannerInterface:
|
|
42
|
+
return self._primary
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def secondary(self) -> ScannerInterface:
|
|
46
|
+
return self._secondary
|
|
47
|
+
|
|
48
|
+
async def is_available(self) -> bool:
|
|
49
|
+
return await self._primary.is_available() or await self._secondary.is_available()
|
|
50
|
+
|
|
51
|
+
async def scan(self, image_reference: str) -> ScanResult:
|
|
52
|
+
result = await self._primary.scan(image_reference)
|
|
53
|
+
if result.is_verified or not result.error_kind.is_scanner_fault:
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
self.fallback_attempts += 1
|
|
57
|
+
logger.warning(
|
|
58
|
+
f"{result.scanner} failed on {image_reference} "
|
|
59
|
+
f"({result.error_kind.value}); retrying with the secondary scanner"
|
|
60
|
+
)
|
|
61
|
+
if not await self._secondary.is_available():
|
|
62
|
+
logger.info("No secondary scanner available; keeping the primary result")
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
fallback = await self._secondary.scan(image_reference)
|
|
66
|
+
if not fallback.is_verified:
|
|
67
|
+
# Nenhum dos dois conseguiu: devolve o resultado do primário, que
|
|
68
|
+
# é o que descreve a falha da ferramenta que deveria ter medido.
|
|
69
|
+
logger.warning(
|
|
70
|
+
f"Secondary scanner also failed on {image_reference} ({fallback.error_kind.value})"
|
|
71
|
+
)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
self.fallback_successes += 1
|
|
75
|
+
logger.info(f"{fallback.scanner} recovered {image_reference} after {result.scanner} failed")
|
|
76
|
+
return fallback
|
|
77
|
+
|
|
78
|
+
async def refresh_db(self) -> bool:
|
|
79
|
+
"""Prepara os dois bancos. O secundário só é útil se estiver pronto
|
|
80
|
+
antes de a primeira falha acontecer."""
|
|
81
|
+
primary_ok = await _refresh(self._primary)
|
|
82
|
+
await _refresh(self._secondary)
|
|
83
|
+
return primary_ok
|
|
84
|
+
|
|
85
|
+
async def close(self) -> None:
|
|
86
|
+
for scanner in (self._primary, self._secondary):
|
|
87
|
+
close = getattr(scanner, "close", None)
|
|
88
|
+
if callable(close):
|
|
89
|
+
await close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _refresh(scanner: ScannerInterface) -> bool:
|
|
93
|
+
refresh = getattr(scanner, "refresh_db", None)
|
|
94
|
+
if not callable(refresh):
|
|
95
|
+
return True
|
|
96
|
+
ok: bool = await refresh()
|
|
97
|
+
return ok
|