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,318 @@
|
|
|
1
|
+
"""Entidades para análise e validação de Dockerfiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from dockerls.domain.security_controls import mapping_for, references_for
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ValidationStatus(StrEnum):
|
|
13
|
+
"""Status de uma validação de Dockerfile."""
|
|
14
|
+
|
|
15
|
+
# "a verificação passou", não uma senha.
|
|
16
|
+
PASS = "PASS" # nosec B105 # noqa: S105
|
|
17
|
+
WARN = "WARN"
|
|
18
|
+
FAIL = "FAIL"
|
|
19
|
+
SKIP = "SKIP"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SeverityLevel(StrEnum):
|
|
23
|
+
"""Níveis de severidade para regras de segurança."""
|
|
24
|
+
|
|
25
|
+
CRITICAL = "CRITICAL"
|
|
26
|
+
HIGH = "HIGH"
|
|
27
|
+
MEDIUM = "MEDIUM"
|
|
28
|
+
LOW = "LOW"
|
|
29
|
+
INFO = "INFO"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ValidationCheck:
|
|
34
|
+
"""Resultado de uma única verificação de validação."""
|
|
35
|
+
|
|
36
|
+
check: str
|
|
37
|
+
status: ValidationStatus
|
|
38
|
+
message: str
|
|
39
|
+
severity: SeverityLevel = SeverityLevel.INFO
|
|
40
|
+
line: int | None = None
|
|
41
|
+
rule_id: str | None = None
|
|
42
|
+
fix_suggestion: str | None = None
|
|
43
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def references(self) -> list[str]:
|
|
47
|
+
"""Published controls this finding implements, as citable strings.
|
|
48
|
+
|
|
49
|
+
Empty when the rule is DockerLs's own guidance rather than a
|
|
50
|
+
published control -- which the renderers state, instead of leaving
|
|
51
|
+
the reader to assume a citation was merely omitted.
|
|
52
|
+
"""
|
|
53
|
+
return references_for(self.rule_id)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def rationale(self) -> str:
|
|
57
|
+
"""Why the rule matters, in terms of what an attacker gains."""
|
|
58
|
+
mapping = mapping_for(self.rule_id)
|
|
59
|
+
return mapping.rationale if mapping else ""
|
|
60
|
+
|
|
61
|
+
def model_dump(self) -> dict[str, Any]:
|
|
62
|
+
"""Retorna dicionário serializável."""
|
|
63
|
+
return {
|
|
64
|
+
"check": self.check,
|
|
65
|
+
"status": self.status.value,
|
|
66
|
+
"message": self.message,
|
|
67
|
+
"severity": self.severity.value,
|
|
68
|
+
"line": self.line,
|
|
69
|
+
"rule_id": self.rule_id,
|
|
70
|
+
# The published controls this rule implements, resolved from the
|
|
71
|
+
# catalogue rather than stored per check: the mapping is static,
|
|
72
|
+
# and duplicating it into every finding is how the two drift.
|
|
73
|
+
# Emitted in the payload because a consumer mapping findings onto
|
|
74
|
+
# a compliance programme should not have to know the catalogue.
|
|
75
|
+
"references": self.references,
|
|
76
|
+
"rationale": self.rationale,
|
|
77
|
+
"fix_suggestion": self.fix_suggestion,
|
|
78
|
+
"details": self.details,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class HardeningRule:
|
|
84
|
+
"""Regra de hardening sugerida para um Dockerfile."""
|
|
85
|
+
|
|
86
|
+
priority: SeverityLevel
|
|
87
|
+
title: str
|
|
88
|
+
description: str
|
|
89
|
+
current_state: str
|
|
90
|
+
suggested_fix: str
|
|
91
|
+
reason: str
|
|
92
|
+
line: int | None = None
|
|
93
|
+
dockerfile_snippet: str | None = None
|
|
94
|
+
|
|
95
|
+
def model_dump(self) -> dict[str, Any]:
|
|
96
|
+
"""Retorna dicionário serializável."""
|
|
97
|
+
return {
|
|
98
|
+
"priority": self.priority.value,
|
|
99
|
+
"title": self.title,
|
|
100
|
+
"description": self.description,
|
|
101
|
+
"current_state": self.current_state,
|
|
102
|
+
"suggested_fix": self.suggested_fix,
|
|
103
|
+
"reason": self.reason,
|
|
104
|
+
"line": self.line,
|
|
105
|
+
"dockerfile_snippet": self.dockerfile_snippet,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class DockerfileValidationResult:
|
|
111
|
+
"""Resultado da validação completa de um Dockerfile."""
|
|
112
|
+
|
|
113
|
+
dockerfile_path: str
|
|
114
|
+
passed: int = 0
|
|
115
|
+
warnings: int = 0
|
|
116
|
+
errors: int = 0
|
|
117
|
+
skipped: int = 0
|
|
118
|
+
checks: list[ValidationCheck] = field(default_factory=list)
|
|
119
|
+
recommendations: list[HardeningRule] = field(default_factory=list)
|
|
120
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def is_valid(self) -> bool:
|
|
124
|
+
"""True se não houver erros críticos."""
|
|
125
|
+
return self.errors == 0
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def total_checks(self) -> int:
|
|
129
|
+
"""Total de verificações realizadas."""
|
|
130
|
+
return self.passed + self.warnings + self.errors + self.skipped
|
|
131
|
+
|
|
132
|
+
def add_check(self, check: ValidationCheck) -> None:
|
|
133
|
+
"""Adiciona um resultado de verificação."""
|
|
134
|
+
self.checks.append(check)
|
|
135
|
+
if check.status == ValidationStatus.PASS:
|
|
136
|
+
self.passed += 1
|
|
137
|
+
elif check.status == ValidationStatus.WARN:
|
|
138
|
+
self.warnings += 1
|
|
139
|
+
elif check.status == ValidationStatus.FAIL:
|
|
140
|
+
self.errors += 1
|
|
141
|
+
elif check.status == ValidationStatus.SKIP:
|
|
142
|
+
self.skipped += 1
|
|
143
|
+
|
|
144
|
+
def model_dump(self) -> dict[str, Any]:
|
|
145
|
+
"""Retorna dicionário serializável."""
|
|
146
|
+
return {
|
|
147
|
+
"dockerfile_path": self.dockerfile_path,
|
|
148
|
+
"passed": self.passed,
|
|
149
|
+
"warnings": self.warnings,
|
|
150
|
+
"errors": self.errors,
|
|
151
|
+
"skipped": self.skipped,
|
|
152
|
+
"total_checks": self.total_checks,
|
|
153
|
+
"is_valid": self.is_valid,
|
|
154
|
+
"checks": [c.model_dump() for c in self.checks],
|
|
155
|
+
"recommendations": [r.model_dump() for r in self.recommendations],
|
|
156
|
+
"metadata": self.metadata,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class DockerfileInfo:
|
|
162
|
+
"""Informações extraídas de um Dockerfile.
|
|
163
|
+
|
|
164
|
+
Os campos de usuário e de base descrevem o **estágio final** -- o único
|
|
165
|
+
que vira a imagem que roda. Um `USER node` num estágio de build não
|
|
166
|
+
protege nada em produção, e por um tempo era exatamente isso que fazia a
|
|
167
|
+
validação dar PASS num container que subia como root.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
base_images: list[str] = field(default_factory=list)
|
|
171
|
+
stages: int = 1
|
|
172
|
+
has_user_directive: bool = False
|
|
173
|
+
user_name: str | None = None
|
|
174
|
+
user_uid: int | None = None
|
|
175
|
+
# Base do estágio final, já resolvida através de `FROM <alias>`.
|
|
176
|
+
final_base_image: str | None = None
|
|
177
|
+
has_healthcheck: bool = False
|
|
178
|
+
has_labels: bool = False
|
|
179
|
+
labels: dict[str, str] = field(default_factory=dict)
|
|
180
|
+
exposes_ports: list[int] = field(default_factory=list)
|
|
181
|
+
entrypoint: str | None = None
|
|
182
|
+
cmd: str | None = None
|
|
183
|
+
uses_buildkit: bool = False
|
|
184
|
+
has_secrets_in_env: bool = False
|
|
185
|
+
secret_env_vars: list[str] = field(default_factory=list)
|
|
186
|
+
package_managers_used: list[str] = field(default_factory=list)
|
|
187
|
+
cache_cleaned: bool = False
|
|
188
|
+
uses_sudo: bool = False
|
|
189
|
+
uses_latest_tag: bool = False
|
|
190
|
+
copy_commands: list[dict[str, Any]] = field(default_factory=list)
|
|
191
|
+
#: Cada `ADD`, com o que nele é diferente de um `COPY`: origens
|
|
192
|
+
#: remotas, arquivos que serão descompactados sozinhos, e se veio com
|
|
193
|
+
#: `--checksum`.
|
|
194
|
+
add_commands: list[dict[str, Any]] = field(default_factory=list)
|
|
195
|
+
run_commands: list[dict[str, Any]] = field(default_factory=list)
|
|
196
|
+
#: `ARG SEGREDO=valor`. Um ARG **com valor** fica no Dockerfile e no
|
|
197
|
+
#: histórico; um ARG sem valor é parâmetro de build e não é infração.
|
|
198
|
+
has_secrets_in_build_args: bool = False
|
|
199
|
+
secret_build_args: list[str] = field(default_factory=list)
|
|
200
|
+
#: `curl ... | sh`: script da rede executado sem nada tê-lo conferido.
|
|
201
|
+
pipes_remote_script_to_shell: bool = False
|
|
202
|
+
remote_script_lines: list[int] = field(default_factory=list)
|
|
203
|
+
#: `chmod u+s` / `chmod 4755`: binário setuid deixado dentro da imagem.
|
|
204
|
+
sets_setuid_bit: bool = False
|
|
205
|
+
setuid_lines: list[int] = field(default_factory=list)
|
|
206
|
+
#: `RUN --mount=type=secret,id=...`: a forma correta do BuildKit de
|
|
207
|
+
#: passar um segredo para um `RUN`, sem ele sobrar em ENV/ARG nem em
|
|
208
|
+
#: nenhuma camada. Distinto de `has_secrets_in_env`/`_build_args`: usar
|
|
209
|
+
#: isto não desculpa um segredo que ainda esteja em ENV ou ARG.
|
|
210
|
+
uses_secret_mount: bool = False
|
|
211
|
+
secret_mount_ids: list[str] = field(default_factory=list)
|
|
212
|
+
#: Pacotes instalados sem versão fixada -- `apt-get install curl` sem
|
|
213
|
+
#: `=<versão>`, `apk add` sem `=`, `pip install` sem `==`, `npm install`
|
|
214
|
+
#: sem `@<versão>`. Reprodutibilidade de build, não confidencialidade:
|
|
215
|
+
#: a mesma linha pode instalar bytes diferentes amanhã.
|
|
216
|
+
unpinned_packages: list[dict[str, Any]] = field(default_factory=list)
|
|
217
|
+
#: `npm install`/`npm ci` presente, mas sem `package-lock.json` ao lado
|
|
218
|
+
#: do Dockerfile -- sem lockfile, nem a versão instalada localmente é
|
|
219
|
+
#: reprodutível no build seguinte.
|
|
220
|
+
npm_lockfile_missing: bool = False
|
|
221
|
+
#: `ARG TARGETPLATFORM`/`TARGETARCH`/`BUILDPLATFORM`, ou `FROM
|
|
222
|
+
#: --platform=...`: o Dockerfile declara ciência de build multi-arch.
|
|
223
|
+
declares_multi_arch: bool = False
|
|
224
|
+
platform_args: list[str] = field(default_factory=list)
|
|
225
|
+
raw_lines: list[str] = field(default_factory=list)
|
|
226
|
+
|
|
227
|
+
def model_dump(self) -> dict[str, Any]:
|
|
228
|
+
"""Retorna dicionário serializável."""
|
|
229
|
+
return {
|
|
230
|
+
"base_images": self.base_images,
|
|
231
|
+
"final_base_image": self.final_base_image,
|
|
232
|
+
"stages": self.stages,
|
|
233
|
+
"has_user_directive": self.has_user_directive,
|
|
234
|
+
"user_name": self.user_name,
|
|
235
|
+
"user_uid": self.user_uid,
|
|
236
|
+
"has_healthcheck": self.has_healthcheck,
|
|
237
|
+
"has_labels": self.has_labels,
|
|
238
|
+
"labels": self.labels,
|
|
239
|
+
"exposes_ports": self.exposes_ports,
|
|
240
|
+
"entrypoint": self.entrypoint,
|
|
241
|
+
"cmd": self.cmd,
|
|
242
|
+
"uses_buildkit": self.uses_buildkit,
|
|
243
|
+
"has_secrets_in_env": self.has_secrets_in_env,
|
|
244
|
+
"secret_env_vars": self.secret_env_vars,
|
|
245
|
+
"package_managers_used": self.package_managers_used,
|
|
246
|
+
"cache_cleaned": self.cache_cleaned,
|
|
247
|
+
"uses_sudo": self.uses_sudo,
|
|
248
|
+
"uses_latest_tag": self.uses_latest_tag,
|
|
249
|
+
"copy_commands": self.copy_commands,
|
|
250
|
+
"add_commands": self.add_commands,
|
|
251
|
+
"run_commands": self.run_commands,
|
|
252
|
+
"has_secrets_in_build_args": self.has_secrets_in_build_args,
|
|
253
|
+
"secret_build_args": self.secret_build_args,
|
|
254
|
+
"pipes_remote_script_to_shell": self.pipes_remote_script_to_shell,
|
|
255
|
+
"sets_setuid_bit": self.sets_setuid_bit,
|
|
256
|
+
"uses_secret_mount": self.uses_secret_mount,
|
|
257
|
+
"secret_mount_ids": self.secret_mount_ids,
|
|
258
|
+
"unpinned_packages": self.unpinned_packages,
|
|
259
|
+
"npm_lockfile_missing": self.npm_lockfile_missing,
|
|
260
|
+
"declares_multi_arch": self.declares_multi_arch,
|
|
261
|
+
"platform_args": self.platform_args,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass
|
|
266
|
+
class DockerfileAnalysis:
|
|
267
|
+
"""Análise completa de um Dockerfile."""
|
|
268
|
+
|
|
269
|
+
info: DockerfileInfo
|
|
270
|
+
validation: DockerfileValidationResult
|
|
271
|
+
security_score: int = 0
|
|
272
|
+
security_tier: str = "C"
|
|
273
|
+
#: Se este Dockerfile parece derivado de um dos templates hardened
|
|
274
|
+
#: (`--base`), alterado à mão depois. Puramente informativo -- não
|
|
275
|
+
#: entra no score nem no tier, que continuam medindo o Dockerfile como
|
|
276
|
+
#: ele é, não de onde ele veio.
|
|
277
|
+
template_origin: TemplateOriginMatch | None = None
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def is_production_ready(self) -> bool:
|
|
281
|
+
"""True se o Dockerfile está pronto para produção."""
|
|
282
|
+
return self.security_tier in ("A", "B") and self.validation.is_valid
|
|
283
|
+
|
|
284
|
+
def model_dump(self) -> dict[str, Any]:
|
|
285
|
+
"""Retorna dicionário serializável."""
|
|
286
|
+
return {
|
|
287
|
+
"info": self.info.model_dump(),
|
|
288
|
+
"validation": self.validation.model_dump(),
|
|
289
|
+
"security_score": self.security_score,
|
|
290
|
+
"security_tier": self.security_tier,
|
|
291
|
+
"is_production_ready": self.is_production_ready,
|
|
292
|
+
"template_origin": self.template_origin.model_dump() if self.template_origin else None,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass
|
|
297
|
+
class TemplateOriginMatch:
|
|
298
|
+
"""A Dockerfile that looks like one of the 39 hardened templates, edited
|
|
299
|
+
by hand afterwards.
|
|
300
|
+
|
|
301
|
+
`similarity` is a plain line-based ratio (`difflib.SequenceMatcher`),
|
|
302
|
+
not a security judgement -- a 60% match is still worth naming, because
|
|
303
|
+
"this drifted from `node-alpine`" is a more useful starting point than
|
|
304
|
+
treating the file as if it came from nowhere. `diff` carries only the
|
|
305
|
+
lines that differ, unified-diff style, so the reader sees the edit
|
|
306
|
+
instead of two full files.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
template_name: str
|
|
310
|
+
similarity: float
|
|
311
|
+
diff: list[str] = field(default_factory=list)
|
|
312
|
+
|
|
313
|
+
def model_dump(self) -> dict[str, Any]:
|
|
314
|
+
return {
|
|
315
|
+
"template_name": self.template_name,
|
|
316
|
+
"similarity": self.similarity,
|
|
317
|
+
"diff": self.diff,
|
|
318
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from dockerls.domain.entities.declared_metadata import DeclaredImageMetadata
|
|
8
|
+
from dockerls.domain.value_objects.image_reference import registry_host_of
|
|
9
|
+
|
|
10
|
+
#: Label carried by candidates discovered on Docker Hub. Defined here rather
|
|
11
|
+
#: than written as a literal in each provider so the source registry, the
|
|
12
|
+
#: results table and the repositories cannot disagree about the spelling.
|
|
13
|
+
DOCKER_HUB = "Docker Hub"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DockerImage(BaseModel):
|
|
17
|
+
name: str
|
|
18
|
+
tag: str
|
|
19
|
+
digest: str = ""
|
|
20
|
+
size_bytes: int = 0
|
|
21
|
+
architecture: str = "amd64"
|
|
22
|
+
available_architectures: list[str] = Field(default_factory=list)
|
|
23
|
+
os: str = "linux"
|
|
24
|
+
last_updated: datetime | None = None
|
|
25
|
+
pull_count: int = 0
|
|
26
|
+
is_official: bool = False
|
|
27
|
+
is_signed: bool = False
|
|
28
|
+
full_reference: str = ""
|
|
29
|
+
# Which catalogue this tag came from: "Docker Hub", "Chainguard",
|
|
30
|
+
# "Distroless". Shown in the results table so a recommendation from a
|
|
31
|
+
# hardened source is identifiable at a glance.
|
|
32
|
+
source: str = DOCKER_HUB
|
|
33
|
+
# What the catalogue that published this image claims about it. Present
|
|
34
|
+
# only for sources that publish build definitions (Docker Hardened
|
|
35
|
+
# Images). A claim, never a measurement -- see DeclaredImageMetadata.
|
|
36
|
+
declared: DeclaredImageMetadata | None = None
|
|
37
|
+
|
|
38
|
+
def model_post_init(self, __context: object) -> None:
|
|
39
|
+
if not self.full_reference:
|
|
40
|
+
self.full_reference = f"{self.name}:{self.tag}"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def registry_host(self) -> str:
|
|
44
|
+
"""Registry this image lives on, or "" for Docker Hub.
|
|
45
|
+
|
|
46
|
+
Delegates to the shared rule so the registry this reports and the
|
|
47
|
+
registry the network policy judges can never diverge -- two copies
|
|
48
|
+
of "what counts as a host" is exactly how `localhost/evil` ends up
|
|
49
|
+
guarded in one place and not the other.
|
|
50
|
+
"""
|
|
51
|
+
return registry_host_of(self.name)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def pinned_reference(self) -> str:
|
|
55
|
+
"""The immutable reference for this image, when one is known.
|
|
56
|
+
|
|
57
|
+
A tag is a moving pointer: `node:22` today and `node:22` next week
|
|
58
|
+
are different bytes, so a recommendation naming only a tag cannot be
|
|
59
|
+
checked against the scan that produced it. When the digest is known
|
|
60
|
+
the pinned form is what should be deployed and what evidence is
|
|
61
|
+
recorded against; without one, the tag is the best available and is
|
|
62
|
+
returned unchanged rather than faked.
|
|
63
|
+
"""
|
|
64
|
+
if not self.digest:
|
|
65
|
+
return self.full_reference
|
|
66
|
+
return f"{self.name}@{self.digest}"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def digest_known(self) -> bool:
|
|
70
|
+
return bool(self.digest)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def age_known(self) -> bool:
|
|
74
|
+
"""Whether this source told us when the tag was last published.
|
|
75
|
+
|
|
76
|
+
The OCI tag-listing API returns names only, so hardened registries
|
|
77
|
+
usually cannot date their tags. Scoring must treat that as "unknown"
|
|
78
|
+
rather than "ancient", or every Chainguard image is penalised for a
|
|
79
|
+
fact the registry simply did not report.
|
|
80
|
+
"""
|
|
81
|
+
return self.last_updated is not None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def age_days(self) -> int:
|
|
85
|
+
if not self.last_updated:
|
|
86
|
+
return 365
|
|
87
|
+
delta = datetime.now(tz=self.last_updated.tzinfo) - self.last_updated
|
|
88
|
+
return max(0, delta.days)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_alpine(self) -> bool:
|
|
92
|
+
return "alpine" in self.tag.lower()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def is_distroless(self) -> bool:
|
|
96
|
+
return "distroless" in self.tag.lower() or "distroless" in self.name.lower()
|
|
97
|
+
|
|
98
|
+
_HARDENED_MARKERS = ("chainguard", "cgr.dev", "wolfi", "bitnami")
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def is_hardened_source(self) -> bool:
|
|
102
|
+
"""True for images from vendors that specialize in minimal,
|
|
103
|
+
security-hardened bases (Chainguard, Wolfi, Bitnami)."""
|
|
104
|
+
name = self.name.lower()
|
|
105
|
+
return any(marker in name for marker in self._HARDENED_MARKERS)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def recently_updated(self) -> bool:
|
|
109
|
+
return self.age_days <= 30
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""What was actually determined about an image, and who determined it.
|
|
2
|
+
|
|
3
|
+
This is the evidence record that the hardening and attack-surface analyses
|
|
4
|
+
read from. Three rules shape it, and all three exist because the alternative
|
|
5
|
+
is a tool that reports confident nonsense:
|
|
6
|
+
|
|
7
|
+
1. **Every fact is three-valued.** A property that could not be determined
|
|
8
|
+
is UNKNOWN, and UNKNOWN never earns credit in either direction.
|
|
9
|
+
|
|
10
|
+
2. **Every fact records its source.** "Runs as non-root" carries a very
|
|
11
|
+
different weight when it comes from the OCI config of a resolved digest
|
|
12
|
+
than when a vendor's build definition claims it. Both are recorded; only
|
|
13
|
+
one is verification.
|
|
14
|
+
|
|
15
|
+
3. **Nothing is inferred from a name.** An image called `distroless` is not
|
|
16
|
+
evidence of anything. The facts here come from the registry, from the
|
|
17
|
+
scanner, or from a catalogue declaration -- never from a substring.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
|
|
26
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EvidenceSource(StrEnum):
|
|
30
|
+
"""Where a fact came from, ordered by how much it is worth.
|
|
31
|
+
|
|
32
|
+
`REGISTRY` and `SCANNER` are measurements of the published artifact.
|
|
33
|
+
`CATALOG` is a vendor's declaration about a build -- useful, auditable,
|
|
34
|
+
and not a measurement. The distinction is carried all the way to the
|
|
35
|
+
JSON output so a consumer can apply its own policy to declared facts.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
#: OCI image config fetched from the registry at a resolved digest.
|
|
39
|
+
REGISTRY = "registry"
|
|
40
|
+
#: Package inventory observed by a vulnerability scanner.
|
|
41
|
+
SCANNER = "scanner"
|
|
42
|
+
#: A build definition published by the image's vendor.
|
|
43
|
+
CATALOG = "catalog"
|
|
44
|
+
#: Nothing determined this.
|
|
45
|
+
NONE = "none"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HardeningFacts(BaseModel):
|
|
49
|
+
"""Per-image evidence, with the origin of each determined fact.
|
|
50
|
+
|
|
51
|
+
Defaults are all UNKNOWN/empty on purpose: an image nobody inspected
|
|
52
|
+
must read as "nothing is known", never as "nothing was found".
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# --- Privilege -------------------------------------------------------
|
|
56
|
+
#: Whether the image's *default* execution account is not root. An image
|
|
57
|
+
#: that sets no USER runs as root, so an empty `User` in a config that
|
|
58
|
+
#: was actually fetched is a determined FALSE, not an unknown.
|
|
59
|
+
runs_as_non_root: Tristate = Tristate.UNKNOWN
|
|
60
|
+
#: The configured user string, verbatim ("node", "65532", "" for unset).
|
|
61
|
+
user: str = ""
|
|
62
|
+
|
|
63
|
+
# --- Contents --------------------------------------------------------
|
|
64
|
+
has_shell: Tristate = Tristate.UNKNOWN
|
|
65
|
+
has_package_manager: Tristate = Tristate.UNKNOWN
|
|
66
|
+
has_debug_tools: Tristate = Tristate.UNKNOWN
|
|
67
|
+
#: SUID/SGID binaries cannot be determined without unpacking the
|
|
68
|
+
#: filesystem, which this tool does not do. Kept as a declared field so
|
|
69
|
+
#: the gap is visible rather than silently absent.
|
|
70
|
+
has_setuid: Tristate = Tristate.UNKNOWN
|
|
71
|
+
#: Packages the image is known to contain. None when nothing counted
|
|
72
|
+
#: them -- which is different from zero.
|
|
73
|
+
package_count: int | None = None
|
|
74
|
+
|
|
75
|
+
# --- Runtime configuration -------------------------------------------
|
|
76
|
+
#: Ports the image declares. Empty list means "declared none" only when
|
|
77
|
+
#: the config was fetched; `ports_known` says which case this is.
|
|
78
|
+
exposed_ports: list[int] = Field(default_factory=list)
|
|
79
|
+
has_healthcheck: Tristate = Tristate.UNKNOWN
|
|
80
|
+
entrypoint: list[str] = Field(default_factory=list)
|
|
81
|
+
cmd: list[str] = Field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
# --- Composition -----------------------------------------------------
|
|
84
|
+
layer_count: int | None = None
|
|
85
|
+
size_bytes: int | None = None
|
|
86
|
+
os_family: str = ""
|
|
87
|
+
|
|
88
|
+
#: fact name -> the source that determined it. A fact absent from this
|
|
89
|
+
#: mapping was never determined by anything.
|
|
90
|
+
evidence: dict[str, EvidenceSource] = Field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
#: True once an OCI config was actually fetched, which is what makes
|
|
93
|
+
#: "no ports declared" and "no USER set" readable as facts.
|
|
94
|
+
config_verified: bool = False
|
|
95
|
+
|
|
96
|
+
#: Places where a catalogue's declaration and the published image
|
|
97
|
+
#: disagree, in the reader's terms. This is the single most valuable
|
|
98
|
+
#: output of comparing declared metadata against a measurement, and it
|
|
99
|
+
#: is surfaced rather than resolved silently: a vendor claiming an image
|
|
100
|
+
#: runs unprivileged while its config says root is exactly the kind of
|
|
101
|
+
#: thing a security tool exists to notice.
|
|
102
|
+
conflicts: list[str] = Field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
def source_of(self, fact: str) -> EvidenceSource:
|
|
105
|
+
return self.evidence.get(fact, EvidenceSource.NONE)
|
|
106
|
+
|
|
107
|
+
def is_verified(self, fact: str) -> bool:
|
|
108
|
+
"""Whether `fact` was measured rather than declared.
|
|
109
|
+
|
|
110
|
+
The ranking layer uses this to keep a vendor's claim from carrying
|
|
111
|
+
the same weight as a registry measurement.
|
|
112
|
+
"""
|
|
113
|
+
return self.source_of(fact) in (EvidenceSource.REGISTRY, EvidenceSource.SCANNER)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def ports_known(self) -> bool:
|
|
117
|
+
return self.config_verified or bool(self.exposed_ports)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def privileged_ports(self) -> list[int]:
|
|
121
|
+
"""Declared ports below 1024.
|
|
122
|
+
|
|
123
|
+
Binding one of these traditionally requires elevated privileges or
|
|
124
|
+
an explicit capability, so an image declaring them is telling us
|
|
125
|
+
something about how it expects to be run.
|
|
126
|
+
"""
|
|
127
|
+
return [port for port in self.exposed_ports if port < 1024]
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def determined_count(self) -> int:
|
|
131
|
+
"""How many facts anything actually determined.
|
|
132
|
+
|
|
133
|
+
Reported alongside every derived score, because a score computed
|
|
134
|
+
over two facts and a score computed over ten are not comparable and
|
|
135
|
+
must not be presented as if they were.
|
|
136
|
+
"""
|
|
137
|
+
return len(self.evidence)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ActionType(StrEnum):
|
|
9
|
+
UPGRADE_IMAGE = "UPGRADE_IMAGE"
|
|
10
|
+
SWITCH_TAG = "SWITCH_TAG"
|
|
11
|
+
REBUILD_IMAGE = "REBUILD_IMAGE"
|
|
12
|
+
UPDATE_PACKAGE = "UPDATE_PACKAGE"
|
|
13
|
+
SWITCH_BASE = "SWITCH_BASE"
|
|
14
|
+
WAIT_UPSTREAM = "WAIT_UPSTREAM"
|
|
15
|
+
RESCAN = "RESCAN"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RemediationStep(BaseModel):
|
|
19
|
+
step_number: int
|
|
20
|
+
action: ActionType
|
|
21
|
+
description: str
|
|
22
|
+
from_value: str = ""
|
|
23
|
+
to_value: str = ""
|
|
24
|
+
expected_impact: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Recommendation(BaseModel):
|
|
28
|
+
image_reference: str
|
|
29
|
+
security_score: float
|
|
30
|
+
tier: str
|
|
31
|
+
remediation_score: int
|
|
32
|
+
steps: list[RemediationStep] = []
|
|
33
|
+
summary: str = ""
|