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,249 @@
|
|
|
1
|
+
"""O `.dockerls-ignore.yaml` dito em OpenVEX, sem inventar a afirmação.
|
|
2
|
+
|
|
3
|
+
Um arquivo de isenção já é um documento VEX em tudo menos no formato: ele
|
|
4
|
+
tem o CVE, tem a justificativa e tem o prazo. O que faltava era emiti-lo
|
|
5
|
+
num formato que o resto do mundo lê -- Trivy e Grype consomem OpenVEX
|
|
6
|
+
nativamente --, para que uma exceção decidida uma vez valesse no pipeline
|
|
7
|
+
inteiro em vez de só dentro desta ferramenta.
|
|
8
|
+
|
|
9
|
+
**A parte difícil, e é onde quase toda implementação erra.** VEX tem quatro
|
|
10
|
+
estados, e o que quase todo mundo emite para uma isenção é `not_affected`.
|
|
11
|
+
Mas `not_affected` é uma afirmação **técnica**: diz que o código vulnerável
|
|
12
|
+
não está presente, ou não é alcançável, ou já está mitigado -- e o padrão
|
|
13
|
+
exige que se diga qual das cinco razões é, de um vocabulário fechado.
|
|
14
|
+
|
|
15
|
+
Uma justificativa em texto livre como "a equipe aceitou o risco até o Q3"
|
|
16
|
+
não é nenhuma das cinco. Traduzi-la para `not_affected` seria transformar
|
|
17
|
+
uma decisão de risco numa afirmação técnica que ninguém fez -- exatamente o
|
|
18
|
+
tipo de substituição que este projeto existe para não cometer.
|
|
19
|
+
|
|
20
|
+
Então:
|
|
21
|
+
|
|
22
|
+
* se a regra **declara** uma das cinco justificativas do padrão, o
|
|
23
|
+
documento sai como `not_affected` com ela;
|
|
24
|
+
* se não declara, sai como `affected` com um `action_statement` carregando
|
|
25
|
+
o texto que a pessoa escreveu. O consumidor vê a exceção, vê o motivo, e
|
|
26
|
+
não recebe uma alegação técnica que a ferramenta inventou.
|
|
27
|
+
|
|
28
|
+
O prazo entra no `action_statement` porque VEX não tem campo para
|
|
29
|
+
expiração: uma isenção sem prazo visível é uma isenção que ninguém revisa.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from datetime import UTC, datetime
|
|
37
|
+
from enum import StrEnum
|
|
38
|
+
from typing import TYPE_CHECKING, Any
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Sequence
|
|
42
|
+
from datetime import date
|
|
43
|
+
|
|
44
|
+
#: A versão do vocabulário que este módulo emite.
|
|
45
|
+
OPENVEX_CONTEXT = "https://openvex.dev/ns/v0.2.0"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class VexStatus(StrEnum):
|
|
49
|
+
"""Os quatro estados do padrão. Não há um quinto para "risco aceito"."""
|
|
50
|
+
|
|
51
|
+
NOT_AFFECTED = "not_affected"
|
|
52
|
+
AFFECTED = "affected"
|
|
53
|
+
FIXED = "fixed"
|
|
54
|
+
UNDER_INVESTIGATION = "under_investigation"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class VexJustification(StrEnum):
|
|
58
|
+
"""O vocabulário fechado que `not_affected` exige.
|
|
59
|
+
|
|
60
|
+
Fechado é o ponto: cada uma destas é uma afirmação verificável sobre o
|
|
61
|
+
código, e é por isso que texto livre não cabe aqui.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
COMPONENT_NOT_PRESENT = "component_not_present"
|
|
65
|
+
VULNERABLE_CODE_NOT_PRESENT = "vulnerable_code_not_present"
|
|
66
|
+
VULNERABLE_CODE_NOT_IN_EXECUTE_PATH = "vulnerable_code_not_in_execute_path"
|
|
67
|
+
VULNERABLE_CODE_CANNOT_BE_CONTROLLED_BY_ADVERSARY = (
|
|
68
|
+
"vulnerable_code_cannot_be_controlled_by_adversary"
|
|
69
|
+
)
|
|
70
|
+
INLINE_MITIGATIONS_ALREADY_EXIST = "inline_mitigations_already_exist"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_justification(value: str) -> VexJustification | None:
|
|
74
|
+
"""A justificativa do padrão, ou None quando o texto não é uma delas.
|
|
75
|
+
|
|
76
|
+
None não é erro: é o caso normal. A maioria das isenções é decisão de
|
|
77
|
+
risco, e o documento sai como `affected` por causa disso.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
return VexJustification(value.strip().lower())
|
|
81
|
+
except ValueError:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class InvalidVexStatementError(ValueError):
|
|
86
|
+
"""Uma afirmação que o padrão não permite emitir.
|
|
87
|
+
|
|
88
|
+
`ValueError` porque a CLI já trata `ValueError` como erro de uso. Ela é
|
|
89
|
+
levantada na construção, e não na serialização, para que o documento
|
|
90
|
+
inválido nunca chegue a existir.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class VexStatement:
|
|
96
|
+
"""Uma afirmação sobre um CVE num produto."""
|
|
97
|
+
|
|
98
|
+
cve: str
|
|
99
|
+
status: VexStatus
|
|
100
|
+
justification: VexJustification | None = None
|
|
101
|
+
action_statement: str = ""
|
|
102
|
+
|
|
103
|
+
def __post_init__(self) -> None:
|
|
104
|
+
"""`not_affected` sem justificativa é a afirmação vazia que o padrão
|
|
105
|
+
proíbe -- e a que este módulo inteiro existe para não emitir.
|
|
106
|
+
|
|
107
|
+
O OpenVEX exige uma das cinco justificativas para `not_affected`
|
|
108
|
+
justamente porque o estado é uma alegação técnica: sem a
|
|
109
|
+
justificativa ele diz "o CVE não se aplica" sem dizer o que foi
|
|
110
|
+
verificado, e um consumidor que o leia suprime o achado com base em
|
|
111
|
+
nada. `statement_for` nunca constrói isso; o invariante mora aqui
|
|
112
|
+
porque a classe é pública e outro chamador o construiria sem saber.
|
|
113
|
+
|
|
114
|
+
Também recusa um CVE vazio: uma afirmação sobre nenhuma
|
|
115
|
+
vulnerabilidade não é uma afirmação, e num documento ela vira uma
|
|
116
|
+
entrada que casa com o que o consumidor decidir.
|
|
117
|
+
"""
|
|
118
|
+
if not self.cve.strip():
|
|
119
|
+
raise InvalidVexStatementError(
|
|
120
|
+
"a VEX statement needs a vulnerability identifier: a statement about "
|
|
121
|
+
"no CVE asserts nothing, and consumers match it against whatever they like"
|
|
122
|
+
)
|
|
123
|
+
if self.status is VexStatus.NOT_AFFECTED and self.justification is None:
|
|
124
|
+
raise InvalidVexStatementError(
|
|
125
|
+
f"'not_affected' for {self.cve} needs one of the five OpenVEX "
|
|
126
|
+
"justifications: without one it claims the vulnerability does not "
|
|
127
|
+
"apply without saying what was checked, and a consumer would suppress "
|
|
128
|
+
"the finding on the strength of nothing. Use 'affected' with an "
|
|
129
|
+
"action_statement to record an accepted risk"
|
|
130
|
+
)
|
|
131
|
+
if self.justification is not None and self.status is not VexStatus.NOT_AFFECTED:
|
|
132
|
+
raise InvalidVexStatementError(
|
|
133
|
+
f"a justification only belongs on 'not_affected'; {self.cve} is "
|
|
134
|
+
f"'{self.status}'. The vocabulary describes why the vulnerable code "
|
|
135
|
+
"cannot be reached, which says nothing about a product that is affected"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def to_dict(self, products: Sequence[str], timestamp: str) -> dict[str, Any]:
|
|
139
|
+
statement: dict[str, Any] = {
|
|
140
|
+
"vulnerability": {"name": self.cve},
|
|
141
|
+
"products": [{"@id": product} for product in products],
|
|
142
|
+
"status": str(self.status),
|
|
143
|
+
"timestamp": timestamp,
|
|
144
|
+
}
|
|
145
|
+
if self.justification is not None:
|
|
146
|
+
statement["justification"] = str(self.justification)
|
|
147
|
+
if self.action_statement:
|
|
148
|
+
# `action_statement` é o campo que o padrão prevê para
|
|
149
|
+
# `affected`, e é onde o texto humano cabe sem virar alegação
|
|
150
|
+
# técnica.
|
|
151
|
+
statement["action_statement"] = self.action_statement
|
|
152
|
+
return statement
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class VexDocument:
|
|
157
|
+
"""Um documento OpenVEX pronto para ser lido por outra ferramenta."""
|
|
158
|
+
|
|
159
|
+
author: str
|
|
160
|
+
products: tuple[str, ...]
|
|
161
|
+
statements: tuple[VexStatement, ...]
|
|
162
|
+
timestamp: str = ""
|
|
163
|
+
version: int = 1
|
|
164
|
+
|
|
165
|
+
def to_dict(self) -> dict[str, Any]:
|
|
166
|
+
stamp = self.timestamp or datetime.now(tz=UTC).isoformat()
|
|
167
|
+
return {
|
|
168
|
+
"@context": OPENVEX_CONTEXT,
|
|
169
|
+
# O `@id` precisa ser estável para o mesmo conteúdo e diferente
|
|
170
|
+
# para conteúdos diferentes; derivá-lo do documento é o que
|
|
171
|
+
# torna dois arquivos comparáveis sem abrir os dois.
|
|
172
|
+
"@id": f"https://openvex.dev/docs/dockerls/{self._fingerprint(stamp)}",
|
|
173
|
+
"author": self.author,
|
|
174
|
+
"timestamp": stamp,
|
|
175
|
+
"version": self.version,
|
|
176
|
+
"statements": [s.to_dict(self.products, stamp) for s in self.statements],
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
def to_json(self, *, indent: int = 2) -> str:
|
|
180
|
+
return json.dumps(self.to_dict(), indent=indent, sort_keys=False)
|
|
181
|
+
|
|
182
|
+
def _fingerprint(self, stamp: str) -> str:
|
|
183
|
+
import hashlib
|
|
184
|
+
|
|
185
|
+
material = "|".join(
|
|
186
|
+
[self.author, stamp, *self.products, *(f"{s.cve}:{s.status}" for s in self.statements)]
|
|
187
|
+
)
|
|
188
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True)
|
|
192
|
+
class ExemptionInput:
|
|
193
|
+
"""O que uma regra de isenção traz, sem depender do modelo que a leu."""
|
|
194
|
+
|
|
195
|
+
cve: str
|
|
196
|
+
justification: str = ""
|
|
197
|
+
expires: date | None = None
|
|
198
|
+
#: A justificativa do padrão, quando quem escreveu declarou uma.
|
|
199
|
+
vex_justification: str = ""
|
|
200
|
+
extra: dict[str, str] = field(default_factory=dict)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def statement_for(exemption: ExemptionInput) -> VexStatement:
|
|
204
|
+
"""Traduz uma isenção, sem transformar risco aceito em alegação técnica."""
|
|
205
|
+
declared = parse_justification(exemption.vex_justification)
|
|
206
|
+
if declared is not None:
|
|
207
|
+
return VexStatement(
|
|
208
|
+
cve=exemption.cve,
|
|
209
|
+
status=VexStatus.NOT_AFFECTED,
|
|
210
|
+
justification=declared,
|
|
211
|
+
action_statement=_with_expiry("", exemption),
|
|
212
|
+
)
|
|
213
|
+
return VexStatement(
|
|
214
|
+
cve=exemption.cve,
|
|
215
|
+
status=VexStatus.AFFECTED,
|
|
216
|
+
action_statement=_with_expiry(
|
|
217
|
+
exemption.justification or "exempted in .dockerls-ignore.yaml, with no reason given",
|
|
218
|
+
exemption,
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _with_expiry(text: str, exemption: ExemptionInput) -> str:
|
|
224
|
+
"""Anexa o prazo, que o VEX não tem campo para carregar.
|
|
225
|
+
|
|
226
|
+
Uma isenção sem prazo visível é uma isenção que ninguém revisa, e o
|
|
227
|
+
prazo é justamente a parte que o arquivo de origem sabe e o documento
|
|
228
|
+
perderia.
|
|
229
|
+
"""
|
|
230
|
+
if exemption.expires is None:
|
|
231
|
+
return text
|
|
232
|
+
note = f"exemption expires on {exemption.expires.isoformat()}"
|
|
233
|
+
return f"{text}; {note}" if text else note
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def build_document(
|
|
237
|
+
exemptions: Sequence[ExemptionInput],
|
|
238
|
+
*,
|
|
239
|
+
products: Sequence[str],
|
|
240
|
+
author: str,
|
|
241
|
+
timestamp: str = "",
|
|
242
|
+
) -> VexDocument:
|
|
243
|
+
"""Monta o documento a partir das isenções ativas."""
|
|
244
|
+
return VexDocument(
|
|
245
|
+
author=author,
|
|
246
|
+
products=tuple(products),
|
|
247
|
+
statements=tuple(statement_for(e) for e in exemptions),
|
|
248
|
+
timestamp=timestamp,
|
|
249
|
+
)
|
dockerls/exit_codes.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The exit code contract every DockerLs command honours.
|
|
2
|
+
|
|
3
|
+
A pipeline can only branch on these numbers, so they are defined once here
|
|
4
|
+
instead of appearing as integer literals scattered through the CLI. The same
|
|
5
|
+
table is documented in the README under "Exit codes".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
# The command ran and nothing violated a policy.
|
|
13
|
+
EXIT_OK: Final[int] = 0
|
|
14
|
+
|
|
15
|
+
# The command could not run to completion: a missing dependency, a network
|
|
16
|
+
# failure, a Dockerfile that does not exist, a failing `docker build`.
|
|
17
|
+
# Nothing was measured, so the result says nothing about security.
|
|
18
|
+
EXIT_ERROR: Final[int] = 1
|
|
19
|
+
|
|
20
|
+
# The command ran fine and the result violates a policy: a validation check
|
|
21
|
+
# failed (`errors > 0`), or `--fail-on` was triggered. This is the code a
|
|
22
|
+
# pipeline gate should treat as "the image is not allowed through".
|
|
23
|
+
EXIT_POLICY: Final[int] = 2
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from dockerls.application.dto.analysis import AnalysisResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExporterInterface(ABC):
|
|
13
|
+
def export(self, result: AnalysisResult, output_path: Path) -> None:
|
|
14
|
+
output_path.write_bytes(self.export_string(result).encode("utf-8"))
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def export_string(self, result: AnalysisResult) -> str: ...
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from dockerls.exporters.base import ExporterInterface
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from dockerls.application.dto.analysis import AnalysisResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CSVExporter(ExporterInterface):
|
|
16
|
+
def export(self, result: AnalysisResult, output_path: Path) -> None:
|
|
17
|
+
output_path.write_bytes(self.export_string(result).encode("utf-8"))
|
|
18
|
+
|
|
19
|
+
def export_string(self, result: AnalysisResult) -> str:
|
|
20
|
+
output = io.StringIO()
|
|
21
|
+
writer = csv.writer(output)
|
|
22
|
+
writer.writerow(
|
|
23
|
+
[
|
|
24
|
+
"Image",
|
|
25
|
+
"Tag",
|
|
26
|
+
"Score",
|
|
27
|
+
"Tier",
|
|
28
|
+
"Critical",
|
|
29
|
+
"High",
|
|
30
|
+
"Medium",
|
|
31
|
+
"Low",
|
|
32
|
+
"Fixable",
|
|
33
|
+
"Remediation Score",
|
|
34
|
+
"EOL",
|
|
35
|
+
"LTS",
|
|
36
|
+
# Appended, never inserted: a consumer indexing by column
|
|
37
|
+
# position keeps working, and one reading the header gets
|
|
38
|
+
# the new dimensions.
|
|
39
|
+
"Source",
|
|
40
|
+
"Digest",
|
|
41
|
+
"Pinned Reference",
|
|
42
|
+
"Hardening",
|
|
43
|
+
"Hardening Coverage",
|
|
44
|
+
"Attack Surface",
|
|
45
|
+
"Confidence",
|
|
46
|
+
"Production Ready",
|
|
47
|
+
"Readiness Blockers",
|
|
48
|
+
"EOL Status",
|
|
49
|
+
"Cross Validation",
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
for a in result.recommendations or result.alternatives:
|
|
53
|
+
writer.writerow(
|
|
54
|
+
[
|
|
55
|
+
a.image.name,
|
|
56
|
+
a.image.tag,
|
|
57
|
+
a.security_score,
|
|
58
|
+
a.tier,
|
|
59
|
+
a.scan.critical_count,
|
|
60
|
+
a.scan.high_count,
|
|
61
|
+
a.scan.medium_count,
|
|
62
|
+
a.scan.low_count,
|
|
63
|
+
a.scan.fixable_count,
|
|
64
|
+
a.remediation_score,
|
|
65
|
+
a.is_eol,
|
|
66
|
+
a.is_lts,
|
|
67
|
+
a.image.source,
|
|
68
|
+
a.image.digest,
|
|
69
|
+
a.pinned_reference,
|
|
70
|
+
# "" rather than 0 when coverage was too thin: a zero
|
|
71
|
+
# here would be read as "no hardening", which is the
|
|
72
|
+
# opposite of "not determined".
|
|
73
|
+
a.hardening.score if a.hardening.reportable else "",
|
|
74
|
+
a.hardening.coverage,
|
|
75
|
+
a.attack_surface.score if a.attack_surface.reportable else "",
|
|
76
|
+
a.confidence.value,
|
|
77
|
+
a.production_ready,
|
|
78
|
+
# Codes, semicolon-separated: a CSV consumer gating on
|
|
79
|
+
# "NOT_MEASURED" should not have to parse a sentence.
|
|
80
|
+
";".join(a.readiness_blockers),
|
|
81
|
+
a.eol_status.value,
|
|
82
|
+
a.cross_validation,
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
return output.getvalue()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from dockerls.exporters.csv_exporter import CSVExporter
|
|
6
|
+
from dockerls.exporters.html_exporter import HTMLExporter
|
|
7
|
+
from dockerls.exporters.json_exporter import JSONExporter
|
|
8
|
+
from dockerls.exporters.markdown_exporter import MarkdownExporter
|
|
9
|
+
from dockerls.exporters.sarif_exporter import SARIFExporter
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from dockerls.exporters.base import ExporterInterface
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExporterFactory:
|
|
16
|
+
_exporters: dict[str, type[ExporterInterface]] = {
|
|
17
|
+
"json": JSONExporter,
|
|
18
|
+
"csv": CSVExporter,
|
|
19
|
+
"html": HTMLExporter,
|
|
20
|
+
"markdown": MarkdownExporter,
|
|
21
|
+
"md": MarkdownExporter,
|
|
22
|
+
"sarif": SARIFExporter,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def create(cls, format_name: str) -> ExporterInterface:
|
|
27
|
+
key = format_name.lower()
|
|
28
|
+
if key not in cls._exporters:
|
|
29
|
+
supported = ", ".join(sorted(cls._exporters.keys()))
|
|
30
|
+
raise ValueError(f"Unsupported format: {format_name}. Supported: {supported}")
|
|
31
|
+
return cls._exporters[key]()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from dockerls.exporters.base import ExporterInterface
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from dockerls.application.dto.analysis import AnalysisResult, ImageAnalysis
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HTMLExporter(ExporterInterface):
|
|
14
|
+
def export(self, result: AnalysisResult, output_path: Path) -> None:
|
|
15
|
+
output_path.write_bytes(self.export_string(result).encode("utf-8"))
|
|
16
|
+
|
|
17
|
+
def export_string(self, result: AnalysisResult) -> str:
|
|
18
|
+
items = result.recommendations or result.alternatives
|
|
19
|
+
rows = "\n".join(self._row(a) for a in items)
|
|
20
|
+
status = "Baseline Met" if result.baseline_met else "Alternative Recommendations"
|
|
21
|
+
return f"""<!DOCTYPE html>
|
|
22
|
+
<html lang="en">
|
|
23
|
+
<head>
|
|
24
|
+
<meta charset="utf-8">
|
|
25
|
+
<title>DockerLs Report - {_esc(result.query)}</title>
|
|
26
|
+
<style>
|
|
27
|
+
body {{ font-family: system-ui, sans-serif; margin: 2rem; background: #fafafa; color: #1a1a1a; }}
|
|
28
|
+
h1 {{ border-bottom: 2px solid #333; padding-bottom: .5rem; }}
|
|
29
|
+
table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
|
|
30
|
+
.note {{ font-size: 0.9em; opacity: 0.8; }}
|
|
31
|
+
th, td {{ border: 1px solid #ddd; padding: 8px 12px; text-align: left; }}
|
|
32
|
+
th {{ background: #333; color: #fff; }}
|
|
33
|
+
tr:nth-child(even) {{ background: #f2f2f2; }}
|
|
34
|
+
.s {{ color: #16a34a; font-weight: bold; }}
|
|
35
|
+
.a {{ color: #2563eb; font-weight: bold; }}
|
|
36
|
+
.b {{ color: #d97706; font-weight: bold; }}
|
|
37
|
+
.c {{ color: #dc2626; font-weight: bold; }}
|
|
38
|
+
.info {{ background: #fff; padding: 1rem; border: 1px solid #ddd;
|
|
39
|
+
border-radius: 4px; margin: 1rem 0; }}
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<h1>DockerLs Security Report</h1>
|
|
44
|
+
<div class="info">
|
|
45
|
+
<p><strong>Query:</strong> {_esc(result.query)}</p>
|
|
46
|
+
<p><strong>Tags Scanned:</strong> {result.total_tags_scanned}</p>
|
|
47
|
+
<p><strong>Status:</strong> {status}</p>
|
|
48
|
+
</div>
|
|
49
|
+
<table>
|
|
50
|
+
<thead><tr>
|
|
51
|
+
<th>Image</th><th>Source</th><th>Score</th><th>Tier</th>
|
|
52
|
+
<th>Critical</th><th>High</th><th>Medium</th><th>Low</th>
|
|
53
|
+
<th>Fixable</th><th>Remediation</th><th>EOL</th>
|
|
54
|
+
<th title="higher is better">Hardening</th>
|
|
55
|
+
<th title="LOWER is better">Attack Surface</th><th>Confidence</th>
|
|
56
|
+
</tr></thead>
|
|
57
|
+
<tbody>{rows}</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
{self._why(result)}
|
|
60
|
+
<p class="note">Hardening and attack surface are scored over the facts that could be
|
|
61
|
+
determined about each image; "n/a" means too little was determined for a number to
|
|
62
|
+
mean anything. Neither is summed into the security score: a well-configured image with
|
|
63
|
+
CRITICAL findings is a well-configured vulnerable image.</p>
|
|
64
|
+
</body>
|
|
65
|
+
</html>"""
|
|
66
|
+
|
|
67
|
+
def _why(self, result: AnalysisResult) -> str:
|
|
68
|
+
"""The case for the top pick, so the ranking is auditable in the report."""
|
|
69
|
+
items = result.recommendations or result.alternatives
|
|
70
|
+
if not items:
|
|
71
|
+
return ""
|
|
72
|
+
best = items[0]
|
|
73
|
+
reasons = "".join(f"<li>{_esc(reason)}</li>" for reason in best.why)
|
|
74
|
+
costs = "".join(f"<li>{_esc(cost)}</li>" for cost in best.trade_offs)
|
|
75
|
+
pinned = (
|
|
76
|
+
f"<p><strong>Pin to:</strong> <code>{_esc(best.pinned_reference)}</code></p>"
|
|
77
|
+
if best.image.digest_known
|
|
78
|
+
else ""
|
|
79
|
+
)
|
|
80
|
+
return (
|
|
81
|
+
f"<h2>Why {_esc(best.image.full_reference)}?</h2>"
|
|
82
|
+
f"<ul>{reasons}</ul>"
|
|
83
|
+
+ (f"<h3>Trade-offs</h3><ul>{costs}</ul>" if costs else "")
|
|
84
|
+
+ f"<p><strong>Confidence:</strong> {_esc(best.confidence.value)} "
|
|
85
|
+
f"({_esc('; '.join(best.confidence_reasons))})</p>" + pinned
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _row(self, a: ImageAnalysis) -> str:
|
|
89
|
+
t = a.tier.lower()
|
|
90
|
+
return (
|
|
91
|
+
f"<tr><td>{_esc(a.image.full_reference)}</td>"
|
|
92
|
+
f"<td>{_esc(a.image.source)}</td><td>{a.security_score}</td>"
|
|
93
|
+
f'<td class="{t}">{a.tier}</td>'
|
|
94
|
+
f"<td>{a.scan.critical_count}</td><td>{a.scan.high_count}</td>"
|
|
95
|
+
f"<td>{a.scan.medium_count}</td><td>{a.scan.low_count}</td>"
|
|
96
|
+
f"<td>{a.scan.fixable_count}</td><td>{a.remediation_score}/100</td>"
|
|
97
|
+
f"<td>{'Yes' if a.is_eol else 'No'}</td>"
|
|
98
|
+
f"<td>{f'{a.hardening.score:.0f}' if a.hardening.reportable else 'n/a'}</td>"
|
|
99
|
+
f"<td>{f'{a.attack_surface.score:.0f}' if a.attack_surface.reportable else 'n/a'}</td>"
|
|
100
|
+
f"<td>{_esc(a.confidence.value)}</td></tr>"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _esc(s: str) -> str:
|
|
105
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from dockerls.exporters.base import ExporterInterface
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from dockerls.application.dto.analysis import AnalysisResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JSONExporter(ExporterInterface):
|
|
15
|
+
def export(self, result: AnalysisResult, output_path: Path) -> None:
|
|
16
|
+
output_path.write_bytes(self.export_string(result).encode("utf-8"))
|
|
17
|
+
|
|
18
|
+
def export_string(self, result: AnalysisResult) -> str:
|
|
19
|
+
return json.dumps(result.model_dump(), indent=2, default=str)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from dockerls.exporters.base import ExporterInterface
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from dockerls.application.dto.analysis import AnalysisResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MarkdownExporter(ExporterInterface):
|
|
14
|
+
def export(self, result: AnalysisResult, output_path: Path) -> None:
|
|
15
|
+
output_path.write_bytes(self.export_string(result).encode("utf-8"))
|
|
16
|
+
|
|
17
|
+
def export_string(self, result: AnalysisResult) -> str:
|
|
18
|
+
items = result.recommendations or result.alternatives
|
|
19
|
+
status = "Baseline Met" if result.baseline_met else "Alternative Recommendations"
|
|
20
|
+
lines = [
|
|
21
|
+
"# DockerLs Security Report",
|
|
22
|
+
"",
|
|
23
|
+
f"**Query:** {result.query}",
|
|
24
|
+
f"**Tags Scanned:** {result.total_tags_scanned}",
|
|
25
|
+
f"**Status:** {status}",
|
|
26
|
+
"",
|
|
27
|
+
"## Results",
|
|
28
|
+
"",
|
|
29
|
+
"| Image | Source | Score | Tier | Critical | High | Medium | Low | Fixable "
|
|
30
|
+
"| Remediation | EOL | Hardening | Attack Surface | Confidence |",
|
|
31
|
+
"|-------|--------|-------|------|----------|------|--------|-----|---------"
|
|
32
|
+
"|-------------|-----|-----------|----------------|------------|",
|
|
33
|
+
]
|
|
34
|
+
for a in items:
|
|
35
|
+
eol = "Yes" if a.is_eol else "No"
|
|
36
|
+
hardening = f"{a.hardening.score:.0f}" if a.hardening.reportable else "n/a"
|
|
37
|
+
surface = f"{a.attack_surface.score:.0f}" if a.attack_surface.reportable else "n/a"
|
|
38
|
+
lines.append(
|
|
39
|
+
f"| {a.image.full_reference} | {a.image.source} | {a.security_score} | {a.tier} "
|
|
40
|
+
f"| {a.scan.critical_count} | {a.scan.high_count} "
|
|
41
|
+
f"| {a.scan.medium_count} | {a.scan.low_count} "
|
|
42
|
+
f"| {a.scan.fixable_count} | {a.remediation_score}/100 | {eol} "
|
|
43
|
+
f"| {hardening} | {surface} | {a.confidence.value} |"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if items:
|
|
47
|
+
best = items[0]
|
|
48
|
+
lines += ["", "## Why this image", ""]
|
|
49
|
+
lines += [f"- {reason}" for reason in best.why] or ["- (no reasons recorded)"]
|
|
50
|
+
if best.trade_offs:
|
|
51
|
+
lines += ["", "### Trade-offs", ""]
|
|
52
|
+
lines += [f"- {cost}" for cost in best.trade_offs]
|
|
53
|
+
if best.image.digest_known:
|
|
54
|
+
lines += ["", f"**Pin to:** `{best.pinned_reference}`"]
|
|
55
|
+
lines += [
|
|
56
|
+
"",
|
|
57
|
+
f"**Confidence:** {best.confidence.value} ({'; '.join(best.confidence_reasons)})",
|
|
58
|
+
"",
|
|
59
|
+
"> Hardening is scored over the facts that could be determined, and its "
|
|
60
|
+
"coverage is reported with it. It is never summed into the security score: "
|
|
61
|
+
"a well-configured image with CRITICAL findings is a well-configured "
|
|
62
|
+
"vulnerable image.",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
if items and items[0].recommendation:
|
|
66
|
+
rec = items[0].recommendation
|
|
67
|
+
lines += [
|
|
68
|
+
"",
|
|
69
|
+
"## Top Recommendation",
|
|
70
|
+
"",
|
|
71
|
+
f"**Image:** {rec.image_reference}",
|
|
72
|
+
f"**Score:** {rec.security_score}",
|
|
73
|
+
f"**Summary:** {rec.summary}",
|
|
74
|
+
]
|
|
75
|
+
if rec.steps:
|
|
76
|
+
lines += ["", "### Remediation Steps", ""]
|
|
77
|
+
for s in rec.steps:
|
|
78
|
+
desc = s.description
|
|
79
|
+
if s.from_value and s.to_value:
|
|
80
|
+
desc += f" ({s.from_value} -> {s.to_value})"
|
|
81
|
+
lines.append(f"{s.step_number}. {desc}")
|
|
82
|
+
|
|
83
|
+
lines.append("")
|
|
84
|
+
return "\n".join(lines)
|