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,195 @@
|
|
|
1
|
+
"""O que se consegue apurar sobre uma imagem *pelo registry*, sem credencial.
|
|
2
|
+
|
|
3
|
+
Auditar a configuração de um registry -- políticas de retenção, IAM, content
|
|
4
|
+
trust -- exige credencial de nuvem e uma API diferente para cada provedor. Não
|
|
5
|
+
é isto aqui. Este módulo é a parte que se mede com o protocolo OCI e mais nada,
|
|
6
|
+
e é deliberadamente essa: um relatório que precisa de acesso administrativo
|
|
7
|
+
para existir é um relatório que ninguém roda.
|
|
8
|
+
|
|
9
|
+
O que dá para apurar assim é menos do que parece e mais do que se costuma
|
|
10
|
+
olhar:
|
|
11
|
+
|
|
12
|
+
* a referência resolve para um digest, e qual;
|
|
13
|
+
* a referência que a pessoa usou já era um digest, ou era uma tag;
|
|
14
|
+
* a tag já mudou de digest desde que esta ferramenta começou a olhar -- que é
|
|
15
|
+
a única evidência *medida* de que ela é mutável, em vez da configuração
|
|
16
|
+
declarada de imutabilidade, que ninguém confere;
|
|
17
|
+
* existe assinatura cosign publicada para aquele digest;
|
|
18
|
+
* existe atestação cosign publicada para aquele digest;
|
|
19
|
+
* o registry respondeu sem nenhuma credencial -- ou seja, a imagem é legível
|
|
20
|
+
por qualquer pessoa da internet.
|
|
21
|
+
|
|
22
|
+
Cada achado é tri-estado por construção. `UNKNOWN` não é um detalhe do formato:
|
|
23
|
+
sem ele, "o registry não respondeu" viraria "não há assinatura", e essa é
|
|
24
|
+
exatamente a substituição que faz um relatório de segurança mentir.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from enum import StrEnum
|
|
31
|
+
from typing import TYPE_CHECKING
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuditCheck(StrEnum):
|
|
38
|
+
"""Cada coisa que se pergunta ao registry."""
|
|
39
|
+
|
|
40
|
+
RESOLVABLE = "RESOLVABLE"
|
|
41
|
+
PINNED_REFERENCE = "PINNED_REFERENCE"
|
|
42
|
+
TAG_STABLE = "TAG_STABLE"
|
|
43
|
+
SIGNATURE_PRESENT = "SIGNATURE_PRESENT"
|
|
44
|
+
ATTESTATION_PRESENT = "ATTESTATION_PRESENT"
|
|
45
|
+
PUBLICLY_READABLE = "PUBLICLY_READABLE"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
#: Checagens que são relatadas e nunca alertam. `PUBLICLY_READABLE` está aqui
|
|
49
|
+
#: porque "público" é o estado *correto* de uma imagem base oficial e o estado
|
|
50
|
+
#: errado de um artefato interno -- e a diferença entre os dois é a intenção de
|
|
51
|
+
#: quem publicou, que esta ferramenta não tem como medir. Transformar o fato em
|
|
52
|
+
#: alerta seria afirmar uma intenção; relatá-lo sem alertar entrega o fato a
|
|
53
|
+
#: quem sabe qual era.
|
|
54
|
+
_INFORMATIONAL = frozenset({AuditCheck.PUBLICLY_READABLE})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class AuditFinding:
|
|
59
|
+
"""Uma resposta do registry, com o que ela significa."""
|
|
60
|
+
|
|
61
|
+
check: AuditCheck
|
|
62
|
+
state: Tristate
|
|
63
|
+
detail: str = ""
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_informational(self) -> bool:
|
|
67
|
+
"""Se é um fato relatado sem juízo de valor. Ver `_INFORMATIONAL`."""
|
|
68
|
+
return self.check in _INFORMATIONAL
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def is_alert(self) -> bool:
|
|
72
|
+
"""Se este achado pede ação.
|
|
73
|
+
|
|
74
|
+
`UNKNOWN` nunca é alerta e nunca é aprovação: é ausência de resposta, e
|
|
75
|
+
entra no relatório como tal.
|
|
76
|
+
"""
|
|
77
|
+
if not self.state.is_known or self.check in _INFORMATIONAL:
|
|
78
|
+
return False
|
|
79
|
+
return self.state.is_false
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def is_unmeasured(self) -> bool:
|
|
83
|
+
return not self.state.is_known
|
|
84
|
+
|
|
85
|
+
def explain(self) -> str:
|
|
86
|
+
return _EXPLANATIONS[self.check][str(self.state)]
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict[str, object]:
|
|
89
|
+
return {
|
|
90
|
+
"check": str(self.check),
|
|
91
|
+
"state": str(self.state),
|
|
92
|
+
"alert": self.is_alert,
|
|
93
|
+
"explanation": self.explain(),
|
|
94
|
+
"detail": self.detail,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_EXPLANATIONS: dict[AuditCheck, dict[str, str]] = {
|
|
99
|
+
AuditCheck.RESOLVABLE: {
|
|
100
|
+
"true": "the registry answered which digest this reference points at",
|
|
101
|
+
"false": (
|
|
102
|
+
"the registry did not resolve this reference: without a digest, nothing "
|
|
103
|
+
"else here can be measured"
|
|
104
|
+
),
|
|
105
|
+
"unknown": "the registry could not be asked",
|
|
106
|
+
},
|
|
107
|
+
AuditCheck.PINNED_REFERENCE: {
|
|
108
|
+
"true": "the reference is already a digest: it points at specific bytes",
|
|
109
|
+
"false": (
|
|
110
|
+
"the reference is a tag: what was tested and what runs can be different "
|
|
111
|
+
"bytes with no change of yours"
|
|
112
|
+
),
|
|
113
|
+
"unknown": "the shape of the reference could not be determined",
|
|
114
|
+
},
|
|
115
|
+
AuditCheck.TAG_STABLE: {
|
|
116
|
+
"true": "this tag has not changed digest since we started watching it",
|
|
117
|
+
"false": (
|
|
118
|
+
"this tag has already changed digest: measured evidence that it is "
|
|
119
|
+
"mutable, whatever the registry immutability setting says"
|
|
120
|
+
),
|
|
121
|
+
"unknown": (
|
|
122
|
+
"there is no history for this tag: what happened before the first "
|
|
123
|
+
"observation is unknown, not absent"
|
|
124
|
+
),
|
|
125
|
+
},
|
|
126
|
+
AuditCheck.SIGNATURE_PRESENT: {
|
|
127
|
+
"true": "a cosign signature is published for this digest",
|
|
128
|
+
"false": (
|
|
129
|
+
"no cosign signature is published for this digest: nobody publicly "
|
|
130
|
+
"attested to producing these bytes"
|
|
131
|
+
),
|
|
132
|
+
"unknown": "the registry could not be asked about the signature",
|
|
133
|
+
},
|
|
134
|
+
AuditCheck.ATTESTATION_PRESENT: {
|
|
135
|
+
"true": "a cosign attestation is published for this digest",
|
|
136
|
+
"false": (
|
|
137
|
+
"no attestation is published for this digest: the registry holds no "
|
|
138
|
+
"record of how this image was built"
|
|
139
|
+
),
|
|
140
|
+
"unknown": "the registry could not be asked about the attestation",
|
|
141
|
+
},
|
|
142
|
+
AuditCheck.PUBLICLY_READABLE: {
|
|
143
|
+
"true": (
|
|
144
|
+
"the registry answered with no credential at all: anyone on the internet "
|
|
145
|
+
"can pull this image and inspect what is inside it. Whether that is a "
|
|
146
|
+
"problem depends on what it exists for, and only you know that part"
|
|
147
|
+
),
|
|
148
|
+
"false": "the registry required a credential to answer",
|
|
149
|
+
"unknown": "whether anonymous access is allowed could not be determined",
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class RegistryAudit:
|
|
156
|
+
"""O conjunto de respostas para uma referência."""
|
|
157
|
+
|
|
158
|
+
reference: str
|
|
159
|
+
digest: str = ""
|
|
160
|
+
findings: tuple[AuditFinding, ...] = field(default_factory=tuple)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def alerts(self) -> tuple[AuditFinding, ...]:
|
|
164
|
+
return tuple(f for f in self.findings if f.is_alert)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def unmeasured(self) -> tuple[AuditFinding, ...]:
|
|
168
|
+
return tuple(f for f in self.findings if f.is_unmeasured)
|
|
169
|
+
|
|
170
|
+
def summary(self) -> str:
|
|
171
|
+
if not self.findings:
|
|
172
|
+
return "nothing could be established about this reference"
|
|
173
|
+
partes = [f"{len(self.alerts)} finding(s) that want attention"]
|
|
174
|
+
if self.unmeasured:
|
|
175
|
+
partes.append(f"{len(self.unmeasured)} not measured")
|
|
176
|
+
return ", ".join(partes)
|
|
177
|
+
|
|
178
|
+
def caveat(self) -> str:
|
|
179
|
+
return (
|
|
180
|
+
"this audit uses the OCI protocol alone, with no cloud credential: it "
|
|
181
|
+
"does not read retention policies, IAM, or the provider immutability "
|
|
182
|
+
"settings. What it measures, it measures for real; what it does not, it "
|
|
183
|
+
"says it did not"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def to_dict(self) -> dict[str, object]:
|
|
187
|
+
return {
|
|
188
|
+
"reference": self.reference,
|
|
189
|
+
"digest": self.digest,
|
|
190
|
+
"summary": self.summary(),
|
|
191
|
+
"caveat": self.caveat(),
|
|
192
|
+
"alerts": len(self.alerts),
|
|
193
|
+
"unmeasured": len(self.unmeasured),
|
|
194
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
195
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Para onde a imagem vai, e o que cada provedor exige antes de aceitar.
|
|
2
|
+
|
|
3
|
+
O `--push` existente rodava `docker push <tag>` com a tag local, exatamente
|
|
4
|
+
como ela foi construída. Numa tag sem host -- `dockerls:1.3.2`, que é a forma
|
|
5
|
+
que todo mundo digita -- isso vira uma tentativa de publicar em
|
|
6
|
+
`docker.io/library/dockerls`, que falha com "denied" para qualquer pessoa que
|
|
7
|
+
não seja mantenedora de uma imagem oficial. E o assistente interativo oferecia
|
|
8
|
+
escolher entre "dockerhub", "ghcr" e "harbor" sem usar a resposta para nada:
|
|
9
|
+
nenhuma delas mudava o destino do push.
|
|
10
|
+
|
|
11
|
+
Este módulo é a peça que faltava. Ele não fala com registry nenhum -- é
|
|
12
|
+
domínio puro, testável sem rede -- e responde três perguntas que precisam de
|
|
13
|
+
resposta *antes* do build começar:
|
|
14
|
+
|
|
15
|
+
* **para onde**, montando a referência completa a partir de host, namespace,
|
|
16
|
+
repositório e tag;
|
|
17
|
+
* **isso é válido para este provedor**, porque as regras diferem de verdade: o
|
|
18
|
+
Artifact Registry do Google exige `projeto/repositório` no caminho, o ACR do
|
|
19
|
+
Azure exige um host `<registro>.azurecr.io`, e o Docker Hub exige um
|
|
20
|
+
namespace que não seja `library`;
|
|
21
|
+
* **como autenticar**, nomeando o comando de login de cada provedor, já que é
|
|
22
|
+
a primeira coisa que falta quando um push é recusado.
|
|
23
|
+
|
|
24
|
+
Perguntar antes do build, e não depois, é o ponto: descobrir que o destino
|
|
25
|
+
está errado depois de escanear e construir desperdiça o trabalho todo, e é
|
|
26
|
+
justamente quando a pessoa está mais propensa a publicar em qualquer lugar só
|
|
27
|
+
para não repetir a espera.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from enum import StrEnum
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RegistryProvider(StrEnum):
|
|
38
|
+
"""Quem hospeda o destino. Determina validação e forma de login."""
|
|
39
|
+
|
|
40
|
+
DOCKER_HUB = "Docker Hub"
|
|
41
|
+
AZURE_ACR = "Azure Container Registry"
|
|
42
|
+
GOOGLE_ARTIFACT_REGISTRY = "Google Artifact Registry"
|
|
43
|
+
GOOGLE_CONTAINER_REGISTRY = "Google Container Registry"
|
|
44
|
+
DHI = "Docker Hardened Images"
|
|
45
|
+
GITHUB_GHCR = "GitHub Container Registry"
|
|
46
|
+
OTHER = "Registry privado"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
#: Hosts que significam Docker Hub.
|
|
50
|
+
_DOCKER_HUB_HOSTS = frozenset({"", "docker.io", "index.docker.io", "registry-1.docker.io"})
|
|
51
|
+
|
|
52
|
+
#: `<registro>.azurecr.io`, incluindo as nuvens soberanas (`.azurecr.cn`,
|
|
53
|
+
#: `.azurecr.us`), que são o mesmo produto em outra geografia.
|
|
54
|
+
_ACR_HOST = re.compile(r"^[a-z0-9]+\.azurecr\.(io|cn|us)$")
|
|
55
|
+
|
|
56
|
+
#: `<região>-docker.pkg.dev` do Artifact Registry.
|
|
57
|
+
_GAR_HOST = re.compile(r"^[a-z0-9-]+-docker\.pkg\.dev$")
|
|
58
|
+
|
|
59
|
+
#: O GCR clássico, incluindo os espelhos regionais (`eu.gcr.io`).
|
|
60
|
+
_GCR_HOST = re.compile(r"^(?:[a-z0-9]+\.)?gcr\.io$")
|
|
61
|
+
|
|
62
|
+
#: Componente de caminho aceito por qualquer registry OCI.
|
|
63
|
+
_PATH_COMPONENT = re.compile(r"^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$")
|
|
64
|
+
|
|
65
|
+
#: Tag OCI.
|
|
66
|
+
_TAG = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def detect_provider(host: str) -> RegistryProvider:
|
|
70
|
+
"""Quem hospeda `host`. Um host desconhecido é registry privado, não erro."""
|
|
71
|
+
value = host.strip().lower()
|
|
72
|
+
if value in _DOCKER_HUB_HOSTS:
|
|
73
|
+
return RegistryProvider.DOCKER_HUB
|
|
74
|
+
if _ACR_HOST.match(value):
|
|
75
|
+
return RegistryProvider.AZURE_ACR
|
|
76
|
+
if _GAR_HOST.match(value):
|
|
77
|
+
return RegistryProvider.GOOGLE_ARTIFACT_REGISTRY
|
|
78
|
+
if _GCR_HOST.match(value):
|
|
79
|
+
return RegistryProvider.GOOGLE_CONTAINER_REGISTRY
|
|
80
|
+
if value == "dhi.io":
|
|
81
|
+
return RegistryProvider.DHI
|
|
82
|
+
if value == "ghcr.io":
|
|
83
|
+
return RegistryProvider.GITHUB_GHCR
|
|
84
|
+
return RegistryProvider.OTHER
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
#: Como autenticar em cada provedor. Nomeado porque é o que falta em quase
|
|
88
|
+
#: todo push recusado, e porque a mensagem genérica do Docker ("denied")
|
|
89
|
+
#: não diz qual comando resolve.
|
|
90
|
+
LOGIN_HINTS: dict[RegistryProvider, str] = {
|
|
91
|
+
RegistryProvider.DOCKER_HUB: (
|
|
92
|
+
"docker login (or `dockerls login`, which stores it in the keyring)"
|
|
93
|
+
),
|
|
94
|
+
RegistryProvider.AZURE_ACR: "az acr login --name <registry>",
|
|
95
|
+
RegistryProvider.GOOGLE_ARTIFACT_REGISTRY: (
|
|
96
|
+
"gcloud auth configure-docker <region>-docker.pkg.dev"
|
|
97
|
+
),
|
|
98
|
+
RegistryProvider.GOOGLE_CONTAINER_REGISTRY: "gcloud auth configure-docker gcr.io",
|
|
99
|
+
RegistryProvider.DHI: "docker login dhi.io (requires a Docker Hardened Images subscription)",
|
|
100
|
+
RegistryProvider.GITHUB_GHCR: (
|
|
101
|
+
"echo $GITHUB_TOKEN | docker login ghcr.io -u <username> --password-stdin"
|
|
102
|
+
),
|
|
103
|
+
RegistryProvider.OTHER: "docker login <host>",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class InvalidRegistryTargetError(ValueError):
|
|
108
|
+
"""O destino não é publicável como está, com o motivo em texto."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class RegistryTarget:
|
|
113
|
+
"""Um destino de publicação completo, validado por provedor.
|
|
114
|
+
|
|
115
|
+
`namespace` é o caminho entre o host e o repositório, e é onde os
|
|
116
|
+
provedores divergem: no Docker Hub é o usuário ou organização; no ACR
|
|
117
|
+
costuma ser vazio ou um agrupamento livre; no Artifact Registry é
|
|
118
|
+
obrigatoriamente `<projeto>/<repositório>`.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
host: str
|
|
122
|
+
repository: str
|
|
123
|
+
tag: str
|
|
124
|
+
namespace: str = ""
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def provider(self) -> RegistryProvider:
|
|
128
|
+
return detect_provider(self.host)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def path(self) -> str:
|
|
132
|
+
parts = [p for p in (self.namespace.strip("/"), self.repository.strip("/")) if p]
|
|
133
|
+
return "/".join(parts)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def reference(self) -> str:
|
|
137
|
+
"""A referência que vai para `docker tag` e `docker push`."""
|
|
138
|
+
host = self.host.strip().strip("/")
|
|
139
|
+
base = f"{host}/{self.path}" if host else self.path
|
|
140
|
+
return f"{base}:{self.tag}"
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def login_hint(self) -> str:
|
|
144
|
+
return LOGIN_HINTS[self.provider]
|
|
145
|
+
|
|
146
|
+
def validate(self) -> None:
|
|
147
|
+
"""Levanta `InvalidRegistryTargetError` com o motivo, ou não faz nada.
|
|
148
|
+
|
|
149
|
+
Validar aqui, antes do build, é o objetivo do módulo: um destino
|
|
150
|
+
malformado descoberto depois do scan custa o build inteiro.
|
|
151
|
+
"""
|
|
152
|
+
if not self.repository.strip():
|
|
153
|
+
raise InvalidRegistryTargetError("the destination repository cannot be empty")
|
|
154
|
+
if not _TAG.match(self.tag or ""):
|
|
155
|
+
raise InvalidRegistryTargetError(f"invalid tag: {self.tag!r}")
|
|
156
|
+
for component in self.path.split("/"):
|
|
157
|
+
if not _PATH_COMPONENT.match(component):
|
|
158
|
+
raise InvalidRegistryTargetError(
|
|
159
|
+
f"invalid path component: {component!r} "
|
|
160
|
+
"(lowercase, digits, and the . _ - separators)"
|
|
161
|
+
)
|
|
162
|
+
self._validate_provider()
|
|
163
|
+
|
|
164
|
+
def _validate_provider(self) -> None:
|
|
165
|
+
provider = self.provider
|
|
166
|
+
if provider is RegistryProvider.DOCKER_HUB:
|
|
167
|
+
# Sem namespace, `docker push` mira `library/<repo>`, que é
|
|
168
|
+
# reservado às imagens oficiais: o push é recusado com "denied" e
|
|
169
|
+
# a mensagem não explica por quê.
|
|
170
|
+
if not self.namespace.strip("/"):
|
|
171
|
+
raise InvalidRegistryTargetError(
|
|
172
|
+
"Docker Hub requires the user or organization as the namespace "
|
|
173
|
+
"(e.g. myorg/dockerls); without it the push targets library/, "
|
|
174
|
+
"which is reserved for official images"
|
|
175
|
+
)
|
|
176
|
+
if self.namespace.strip("/").lower() == "library":
|
|
177
|
+
raise InvalidRegistryTargetError(
|
|
178
|
+
"`library` is the namespace of Docker Hub official images and "
|
|
179
|
+
"does not accept third-party publishing"
|
|
180
|
+
)
|
|
181
|
+
elif provider is RegistryProvider.GOOGLE_ARTIFACT_REGISTRY:
|
|
182
|
+
# gcr.io aceitava `projeto/imagem`; o Artifact Registry exige o
|
|
183
|
+
# repositório no caminho, e omiti-lo falha só na hora do push.
|
|
184
|
+
if len(self.path.split("/")) < 3:
|
|
185
|
+
raise InvalidRegistryTargetError(
|
|
186
|
+
"Artifact Registry requires <project>/<repository>/<image> in the "
|
|
187
|
+
"path (e.g. my-project/containers/dockerls)"
|
|
188
|
+
)
|
|
189
|
+
elif provider is RegistryProvider.DHI:
|
|
190
|
+
raise InvalidRegistryTargetError(
|
|
191
|
+
"dhi.io is a Docker image catalogue, not a publish destination: it "
|
|
192
|
+
"distributes hardened images and does not accept pushes"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def parse(cls, destination: str, tag: str) -> RegistryTarget:
|
|
197
|
+
"""Monta um destino a partir de `host/namespace/repo` e uma tag.
|
|
198
|
+
|
|
199
|
+
O host é reconhecido pela mesma regra do Docker -- primeiro componente
|
|
200
|
+
com ponto, dois-pontos, ou igual a `localhost` --, e o que sobra é
|
|
201
|
+
dividido em namespace e repositório.
|
|
202
|
+
"""
|
|
203
|
+
value = destination.strip().strip("/")
|
|
204
|
+
if not value:
|
|
205
|
+
raise InvalidRegistryTargetError("empty destination")
|
|
206
|
+
# Uma tag embutida no destino é ambiguidade, não conveniência: qual
|
|
207
|
+
# das duas vale, a de `--tag` ou a colada aqui?
|
|
208
|
+
head = value.split("/", 1)[0]
|
|
209
|
+
if ":" in value.rsplit("/", 1)[-1]:
|
|
210
|
+
raise InvalidRegistryTargetError(
|
|
211
|
+
"give the destination without a tag; the tag comes from --tag so "
|
|
212
|
+
"there are never two"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if "." in head or ":" in head or head.lower() == "localhost":
|
|
216
|
+
host, _, rest = value.partition("/")
|
|
217
|
+
else:
|
|
218
|
+
host, rest = "", value
|
|
219
|
+
if not rest:
|
|
220
|
+
raise InvalidRegistryTargetError(
|
|
221
|
+
f"the destination {destination!r} does not name a repository"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
namespace, _, repository = rest.rpartition("/")
|
|
225
|
+
return cls(host=host, repository=repository, tag=tag, namespace=namespace)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from dockerls.domain.entities.scan_result import ScanResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
#: Faixas de `fixable / total` e a nota que cada uma vale. É uma escala
|
|
10
|
+
#: ordinal de cinco degraus, deliberadamente -- não uma porcentagem. A
|
|
11
|
+
#: proporção exata oscila a cada atualização de banco de vulnerabilidades, e
|
|
12
|
+
#: um número que muda de 41% para 39% sozinho sugere uma precisão que o dado
|
|
13
|
+
#: não tem. O degrau responde à pergunta que interessa: "dá para consertar a
|
|
14
|
+
#: maior parte disto?".
|
|
15
|
+
_BANDS: tuple[tuple[float, int], ...] = (
|
|
16
|
+
(1.0, 100),
|
|
17
|
+
(0.75, 80),
|
|
18
|
+
(0.5, 60),
|
|
19
|
+
(0.25, 40),
|
|
20
|
+
)
|
|
21
|
+
_LOWEST_BAND = 20
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RemediationScore:
|
|
25
|
+
"""Quão remediável é um scan, numa escala ordinal de 0 a 100.
|
|
26
|
+
|
|
27
|
+
**Não é a porcentagem de vulnerabilidades corrigíveis.** É a razão
|
|
28
|
+
`fixable / total` mapeada em cinco degraus:
|
|
29
|
+
|
|
30
|
+
| fixable / total | nota |
|
|
31
|
+
|-----------------|------|
|
|
32
|
+
| 100% | 100 |
|
|
33
|
+
| >= 75% | 80 |
|
|
34
|
+
| >= 50% | 60 |
|
|
35
|
+
| >= 25% | 40 |
|
|
36
|
+
| < 25% | 20 |
|
|
37
|
+
| sem vulns | 100 |
|
|
38
|
+
|
|
39
|
+
Ou seja: 16 corrigíveis em 170 (9,4%) valem **20**, e isso está correto.
|
|
40
|
+
Ler esse 20 como "20%" -- que era o que o terminal sugeria ao imprimir um
|
|
41
|
+
`%` ao lado -- faz o número parecer errado por um fator de dois. Quem
|
|
42
|
+
quiser a proporção crua a tem impressa ao lado, em `analyze`.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, scan: ScanResult):
|
|
46
|
+
self._scan = scan
|
|
47
|
+
self._value = self._calculate()
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def value(self) -> int:
|
|
51
|
+
return self._value
|
|
52
|
+
|
|
53
|
+
def _calculate(self) -> int:
|
|
54
|
+
total = self._scan.total_count
|
|
55
|
+
if total == 0:
|
|
56
|
+
return 100
|
|
57
|
+
|
|
58
|
+
ratio = self._scan.fixable_count / total
|
|
59
|
+
for threshold, score in _BANDS:
|
|
60
|
+
if ratio >= threshold:
|
|
61
|
+
return score
|
|
62
|
+
return _LOWEST_BAND
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""How an image's vulnerability counts moved across scans over time.
|
|
2
|
+
|
|
3
|
+
`tag_history.py` answers "did the bytes behind this reference change";
|
|
4
|
+
this answers the question underneath it -- "when this reference was
|
|
5
|
+
scanned again, did the count of findings go up or down". Neither implies
|
|
6
|
+
the other: a tag can move to a new digest with fewer CVEs, or stay on the
|
|
7
|
+
exact same digest while the scanner's database learns about a new one
|
|
8
|
+
between two runs.
|
|
9
|
+
|
|
10
|
+
Same two choices as `tag_history.py`, for the same reasons:
|
|
11
|
+
|
|
12
|
+
* **Only a change enters.** Scanning the same reference twice with the
|
|
13
|
+
same digest and the same counts is not a second event; recording it
|
|
14
|
+
anyway would bury the actual changes in noise.
|
|
15
|
+
* **The first observation is never the one pruning drops.** It anchors
|
|
16
|
+
"since when", and a history that starts at an arbitrary later point is
|
|
17
|
+
a history that lies about how long it has been watching.
|
|
18
|
+
|
|
19
|
+
Nothing here claims a reference was ever *clean*: the history begins at
|
|
20
|
+
the first scan this tool happened to run, and whatever came before that is
|
|
21
|
+
unknown, not absent.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field, replace
|
|
27
|
+
|
|
28
|
+
#: Same ceiling as `tag_history.py`, for the same reason: enough
|
|
29
|
+
#: observations to see a trend, small enough that a whole fleet's history
|
|
30
|
+
#: does not turn the cache into a database by accident.
|
|
31
|
+
MAX_OBSERVATIONS = 24
|
|
32
|
+
|
|
33
|
+
#: The dimensions tracked and, in this order, reported when they change.
|
|
34
|
+
_FIELDS = ("critical", "high", "medium", "low", "total")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ScanObservation:
|
|
39
|
+
"""One scan's counts, and when this tool saw them."""
|
|
40
|
+
|
|
41
|
+
digest: str
|
|
42
|
+
observed_at: str
|
|
43
|
+
critical: int = 0
|
|
44
|
+
high: int = 0
|
|
45
|
+
medium: int = 0
|
|
46
|
+
low: int = 0
|
|
47
|
+
total: int = 0
|
|
48
|
+
|
|
49
|
+
def counts(self) -> tuple[int, ...]:
|
|
50
|
+
"""The tracked dimensions as a tuple, in `_FIELDS` order -- for
|
|
51
|
+
comparing two observations without naming every field."""
|
|
52
|
+
return tuple(getattr(self, name) for name in _FIELDS)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, object]:
|
|
55
|
+
return {
|
|
56
|
+
"digest": self.digest,
|
|
57
|
+
"observed_at": self.observed_at,
|
|
58
|
+
**{name: getattr(self, name) for name in _FIELDS},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def from_dict(raw: object) -> ScanObservation | None:
|
|
63
|
+
"""An observation from the cache, or `None` when the row is unusable.
|
|
64
|
+
|
|
65
|
+
The cache is content from outside this call: a corrupted entry or
|
|
66
|
+
one written by an earlier version must not take down a scan.
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(raw, dict):
|
|
69
|
+
return None
|
|
70
|
+
digest = raw.get("digest")
|
|
71
|
+
observed = raw.get("observed_at")
|
|
72
|
+
if not isinstance(digest, str) or not isinstance(observed, str) or not digest.strip():
|
|
73
|
+
return None
|
|
74
|
+
counts: dict[str, int] = {}
|
|
75
|
+
for name in _FIELDS:
|
|
76
|
+
value = raw.get(name, 0)
|
|
77
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
78
|
+
return None
|
|
79
|
+
counts[name] = value
|
|
80
|
+
return ScanObservation(digest=digest.strip(), observed_at=observed.strip(), **counts)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class ScanHistory:
|
|
85
|
+
"""What is known about a reference's vulnerability counts, in order."""
|
|
86
|
+
|
|
87
|
+
reference: str
|
|
88
|
+
observations: tuple[ScanObservation, ...] = field(default_factory=tuple)
|
|
89
|
+
#: Same accounting as `TagHistory.dropped`: observations pruned off the
|
|
90
|
+
#: front, counted rather than silently forgotten.
|
|
91
|
+
dropped: int = 0
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def is_empty(self) -> bool:
|
|
95
|
+
return not self.observations
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def scans(self) -> int:
|
|
99
|
+
"""Total scans this history has ever recorded, pruned ones included."""
|
|
100
|
+
return len(self.observations) + self.dropped
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def first_seen(self) -> str:
|
|
104
|
+
return self.observations[0].observed_at if self.observations else ""
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def latest(self) -> ScanObservation | None:
|
|
108
|
+
return self.observations[-1] if self.observations else None
|
|
109
|
+
|
|
110
|
+
def explain(self) -> str:
|
|
111
|
+
"""The sentence that turns two counts into a fact worth reading."""
|
|
112
|
+
if self.is_empty:
|
|
113
|
+
return "first scan recorded for this reference: there is no history to compare against"
|
|
114
|
+
if len(self.observations) == 1:
|
|
115
|
+
return f"first scan recorded on {self.first_seen}; nothing to compare it to yet"
|
|
116
|
+
|
|
117
|
+
previous, current = self.observations[-2], self.observations[-1]
|
|
118
|
+
deltas = _describe_deltas(previous, current)
|
|
119
|
+
if not deltas:
|
|
120
|
+
return f"unchanged since the previous scan on {previous.observed_at}"
|
|
121
|
+
return f"since {previous.observed_at}: " + ", ".join(deltas)
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict[str, object]:
|
|
124
|
+
return {
|
|
125
|
+
"reference": self.reference,
|
|
126
|
+
"scans": self.scans,
|
|
127
|
+
"dropped_observations": self.dropped,
|
|
128
|
+
"first_seen": self.first_seen,
|
|
129
|
+
"explanation": self.explain(),
|
|
130
|
+
"observations": [o.to_dict() for o in self.observations],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def from_dict(reference: str, raw: object) -> ScanHistory:
|
|
135
|
+
"""Reconstructs from the cache, discarding whatever does not parse.
|
|
136
|
+
|
|
137
|
+
An unreadable history becomes an empty one -- which is honest:
|
|
138
|
+
nothing usable is known about this reference. Never an exception:
|
|
139
|
+
this is an extra on top of the scan, not something that may block it.
|
|
140
|
+
"""
|
|
141
|
+
if not isinstance(raw, dict):
|
|
142
|
+
return ScanHistory(reference=reference)
|
|
143
|
+
entries = raw.get("observations")
|
|
144
|
+
if not isinstance(entries, list):
|
|
145
|
+
return ScanHistory(reference=reference)
|
|
146
|
+
parsed = [o for o in (ScanObservation.from_dict(e) for e in entries) if o is not None]
|
|
147
|
+
dropped = raw.get("dropped_observations")
|
|
148
|
+
return ScanHistory(
|
|
149
|
+
reference=reference,
|
|
150
|
+
observations=tuple(parsed),
|
|
151
|
+
dropped=dropped if isinstance(dropped, int) and dropped >= 0 else 0,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _describe_deltas(previous: ScanObservation, current: ScanObservation) -> list[str]:
|
|
156
|
+
deltas = []
|
|
157
|
+
for name in _FIELDS:
|
|
158
|
+
change = getattr(current, name) - getattr(previous, name)
|
|
159
|
+
if change:
|
|
160
|
+
deltas.append(f"{name} {change:+d}")
|
|
161
|
+
return deltas
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def record(history: ScanHistory, observation: ScanObservation) -> ScanHistory:
|
|
165
|
+
"""The history with this observation incorporated.
|
|
166
|
+
|
|
167
|
+
An observation whose digest and every count match the last one
|
|
168
|
+
recorded changes nothing worth saying, so it is dropped -- the same
|
|
169
|
+
rule `tag_history.record` applies to a tag that did not move.
|
|
170
|
+
"""
|
|
171
|
+
if not observation.digest.strip():
|
|
172
|
+
return history
|
|
173
|
+
if history.observations:
|
|
174
|
+
last = history.observations[-1]
|
|
175
|
+
if last.digest == observation.digest and last.counts() == observation.counts():
|
|
176
|
+
return history
|
|
177
|
+
|
|
178
|
+
entries = (*history.observations, observation)
|
|
179
|
+
dropped = history.dropped
|
|
180
|
+
if len(entries) > MAX_OBSERVATIONS:
|
|
181
|
+
dropped += len(entries) - MAX_OBSERVATIONS
|
|
182
|
+
entries = (entries[0], *entries[-(MAX_OBSERVATIONS - 1) :])
|
|
183
|
+
return replace(history, observations=entries, dropped=dropped)
|