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,245 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from dockerls import __version__
|
|
8
|
+
from dockerls.domain.entities.vulnerability import Severity
|
|
9
|
+
from dockerls.exporters.base import ExporterInterface
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from dockerls.application.dto.analysis import AnalysisResult, ImageAnalysis
|
|
15
|
+
|
|
16
|
+
_SEVERITY_TO_LEVEL = {
|
|
17
|
+
Severity.CRITICAL: "error",
|
|
18
|
+
Severity.HIGH: "error",
|
|
19
|
+
Severity.MEDIUM: "warning",
|
|
20
|
+
Severity.LOW: "note",
|
|
21
|
+
Severity.UNKNOWN: "note",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# GitHub code scanning reads `security-severity` as a number and buckets it:
|
|
25
|
+
# >= 9.0 critical, >= 7.0 high, >= 4.0 medium, > 0.0 low. These are the floor
|
|
26
|
+
# of each bucket, used only when the scanner classified a finding without
|
|
27
|
+
# publishing a CVSS score -- which is the *normal* case for Debian, Alpine and
|
|
28
|
+
# Ubuntu advisories, as `TrivyScanner._extract_cvss` documents.
|
|
29
|
+
#
|
|
30
|
+
# Emitting the literal 0.0 for those, as this exporter used to, filed every
|
|
31
|
+
# unscored CRITICAL in the security dashboard at the bottom of the scale. The
|
|
32
|
+
# severity the scanner actually assigned was thrown away on the way out.
|
|
33
|
+
#
|
|
34
|
+
# This is a translation of a category into GitHub's numeric channel, not an
|
|
35
|
+
# invented measurement, so the rule records which of the two it was in
|
|
36
|
+
# `properties.severity-source`.
|
|
37
|
+
_SEVERITY_FLOOR = {
|
|
38
|
+
Severity.CRITICAL: 9.0,
|
|
39
|
+
Severity.HIGH: 7.0,
|
|
40
|
+
Severity.MEDIUM: 4.0,
|
|
41
|
+
Severity.LOW: 1.0,
|
|
42
|
+
Severity.UNKNOWN: 0.0,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# SARIF requires `reportingDescriptor.id` to be a non-empty string, and a
|
|
46
|
+
# scanner can report a finding with no advisory ID at all (Trivy leaves
|
|
47
|
+
# `VulnerabilityID` empty). Grouping those by package keeps the document
|
|
48
|
+
# valid and keeps unrelated unnamed findings from collapsing into one rule.
|
|
49
|
+
_UNIDENTIFIED_PREFIX = "DOCKERLS-UNIDENTIFIED"
|
|
50
|
+
|
|
51
|
+
# The URL published in `$schema`. The document points a consumer at the
|
|
52
|
+
# schema it claims to satisfy, so the URL has to resolve: the previous one
|
|
53
|
+
# (`.../sarif-spec/master/Schemata/...`) 404s -- the repository's default
|
|
54
|
+
# branch was renamed and the schema moved under `sarif-2.1/schema/`.
|
|
55
|
+
_SCHEMA_URL = (
|
|
56
|
+
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/"
|
|
57
|
+
"sarif-2.1/schema/sarif-schema-2.1.0.json"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# `artifactLocation.uri` is required to be a URI reference, and a SARIF
|
|
61
|
+
# consumer keys findings by it. An analysis carrying no usable reference
|
|
62
|
+
# would otherwise render as `":"` -- `f"{name}:{tag}"` over two empty
|
|
63
|
+
# strings -- which is neither a URI reference nor an identifier.
|
|
64
|
+
_UNKNOWN_ARTIFACT = "unknown-image"
|
|
65
|
+
|
|
66
|
+
# The upper bound of the CVSS v3 base-score range. Anything outside
|
|
67
|
+
# `0 < score <= 10` did not come from a CVSS calculator.
|
|
68
|
+
_CVSS_MAX = 10.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SARIFExporter(ExporterInterface):
|
|
72
|
+
"""Exports scan findings as SARIF 2.1.0 for consumption by GitHub code
|
|
73
|
+
scanning and other SARIF-aware tooling."""
|
|
74
|
+
|
|
75
|
+
def export(self, result: AnalysisResult, output_path: Path) -> None:
|
|
76
|
+
output_path.write_bytes(self.export_string(result).encode("utf-8"))
|
|
77
|
+
|
|
78
|
+
def export_string(self, result: AnalysisResult) -> str:
|
|
79
|
+
images: list[ImageAnalysis] = [*result.recommendations, *result.alternatives]
|
|
80
|
+
rules: dict[str, dict[str, Any]] = {}
|
|
81
|
+
sarif_results: list[dict[str, Any]] = []
|
|
82
|
+
|
|
83
|
+
for analysis in images:
|
|
84
|
+
for vuln in analysis.scan.vulnerabilities:
|
|
85
|
+
rule_id = _rule_id(vuln)
|
|
86
|
+
if rule_id not in rules:
|
|
87
|
+
rules[rule_id] = _rule_for(vuln, rule_id)
|
|
88
|
+
sarif_results.append(
|
|
89
|
+
{
|
|
90
|
+
"ruleId": rule_id,
|
|
91
|
+
"level": _SEVERITY_TO_LEVEL.get(vuln.severity, "warning"),
|
|
92
|
+
"message": {
|
|
93
|
+
"text": (
|
|
94
|
+
f"{vuln.severity.value} vulnerability in "
|
|
95
|
+
f"{vuln.package_name} {vuln.installed_version}"
|
|
96
|
+
+ (f" (fix: {vuln.fixed_version})" if vuln.fixed_version else "")
|
|
97
|
+
)
|
|
98
|
+
},
|
|
99
|
+
"locations": [
|
|
100
|
+
{
|
|
101
|
+
"physicalLocation": {
|
|
102
|
+
"artifactLocation": {"uri": _artifact_uri(analysis)}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
],
|
|
106
|
+
# Per-result rather than per-run: a SARIF file can
|
|
107
|
+
# carry findings from several images, and a consumer
|
|
108
|
+
# gating on confidence needs to know which image an
|
|
109
|
+
# UNVERIFIED verdict belongs to. `properties` is the
|
|
110
|
+
# spec's extension point, so nothing existing moves.
|
|
111
|
+
"properties": _image_properties(analysis),
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
sarif = {
|
|
116
|
+
"version": "2.1.0",
|
|
117
|
+
"$schema": _SCHEMA_URL,
|
|
118
|
+
"runs": [
|
|
119
|
+
{
|
|
120
|
+
"tool": {
|
|
121
|
+
"driver": {
|
|
122
|
+
"name": "DockerLs",
|
|
123
|
+
"informationUri": "https://github.com/Ivomsantiago/DockerLs",
|
|
124
|
+
"version": __version__,
|
|
125
|
+
"rules": list(rules.values()),
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"results": sarif_results,
|
|
129
|
+
}
|
|
130
|
+
],
|
|
131
|
+
}
|
|
132
|
+
# `allow_nan=False` is the assertion, `_json_safe` is what keeps it
|
|
133
|
+
# from firing. Python's default is to emit the bare tokens `NaN` and
|
|
134
|
+
# `Infinity`, which RFC 8259 does not allow: a single non-finite
|
|
135
|
+
# score anywhere in the document makes the *whole file* unparseable
|
|
136
|
+
# to a strict reader, and GitHub code scanning's ingester is one.
|
|
137
|
+
# Every finding in the upload is then discarded together -- the
|
|
138
|
+
# failure mode this project exists to refuse, a security report that
|
|
139
|
+
# silently reports nothing.
|
|
140
|
+
return json.dumps(_json_safe(sarif), indent=2, allow_nan=False, default=str)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _json_safe(value: Any) -> Any:
|
|
144
|
+
"""Replace non-finite floats with `None`, recursively.
|
|
145
|
+
|
|
146
|
+
A `NaN` score is not a measurement, so it is not published as one: the
|
|
147
|
+
property is emitted as JSON `null`, which a consumer reads as "no value"
|
|
148
|
+
rather than as a number that happens to compare falsely against every
|
|
149
|
+
threshold.
|
|
150
|
+
"""
|
|
151
|
+
if isinstance(value, float):
|
|
152
|
+
return value if math.isfinite(value) else None
|
|
153
|
+
if isinstance(value, dict):
|
|
154
|
+
return {key: _json_safe(item) for key, item in value.items()}
|
|
155
|
+
if isinstance(value, list | tuple):
|
|
156
|
+
return [_json_safe(item) for item in value]
|
|
157
|
+
return value
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _artifact_uri(analysis: ImageAnalysis) -> str:
|
|
161
|
+
"""A non-empty URI reference naming the scanned image."""
|
|
162
|
+
for candidate in (analysis.image.full_reference, analysis.scan.image_reference):
|
|
163
|
+
text = (candidate or "").strip().strip(":")
|
|
164
|
+
if text:
|
|
165
|
+
return text
|
|
166
|
+
return _UNKNOWN_ARTIFACT
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _image_properties(analysis: ImageAnalysis) -> dict[str, Any]:
|
|
170
|
+
"""Image-level context attached to every finding from that image.
|
|
171
|
+
|
|
172
|
+
The digest is included whenever one was resolved: a SARIF file that
|
|
173
|
+
names only a tag cannot be matched back to the bytes that were scanned.
|
|
174
|
+
"""
|
|
175
|
+
properties: dict[str, Any] = {
|
|
176
|
+
"image": analysis.image.full_reference,
|
|
177
|
+
"source": analysis.image.source,
|
|
178
|
+
"securityScore": analysis.security_score,
|
|
179
|
+
"tier": analysis.tier,
|
|
180
|
+
"confidence": analysis.confidence.value,
|
|
181
|
+
"productionReady": analysis.production_ready,
|
|
182
|
+
"eolStatus": analysis.eol_status.value,
|
|
183
|
+
"crossValidation": analysis.cross_validation,
|
|
184
|
+
}
|
|
185
|
+
if analysis.readiness_blockers:
|
|
186
|
+
properties["readinessBlockers"] = list(analysis.readiness_blockers)
|
|
187
|
+
if analysis.image.digest_known:
|
|
188
|
+
properties["digest"] = analysis.image.digest
|
|
189
|
+
properties["pinnedReference"] = analysis.pinned_reference
|
|
190
|
+
if analysis.hardening.reportable:
|
|
191
|
+
properties["hardeningScore"] = analysis.hardening.score
|
|
192
|
+
properties["hardeningCoverage"] = analysis.hardening.coverage
|
|
193
|
+
if analysis.attack_surface.reportable:
|
|
194
|
+
properties["attackSurfaceScore"] = analysis.attack_surface.score
|
|
195
|
+
return properties
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _rule_id(vuln: Any) -> str:
|
|
199
|
+
"""A stable, non-empty rule identifier for a finding."""
|
|
200
|
+
cve_id = (vuln.cve_id or "").strip()
|
|
201
|
+
if cve_id:
|
|
202
|
+
return cve_id
|
|
203
|
+
package = (vuln.package_name or "").strip() or "unknown-package"
|
|
204
|
+
return f"{_UNIDENTIFIED_PREFIX}-{package}"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _security_severity(vuln: Any) -> tuple[str, str]:
|
|
208
|
+
"""Return (value, source) for GitHub's `security-severity` property.
|
|
209
|
+
|
|
210
|
+
`cvss` means the number is the scanner's measured CVSS base score.
|
|
211
|
+
`severity-band` means the advisory carried no score, so the floor of the
|
|
212
|
+
bucket matching the severity the scanner assigned is used instead --
|
|
213
|
+
otherwise an unscored CRITICAL is published to code scanning as 0.0.
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
score = float(vuln.cvss_score)
|
|
217
|
+
except (TypeError, ValueError):
|
|
218
|
+
score = 0.0
|
|
219
|
+
# GitHub parses `security-severity` as a number. A score that is not a
|
|
220
|
+
# finite value inside the CVSS range never came from a calculator, and
|
|
221
|
+
# publishing it verbatim put the strings "inf" and "nan" in that channel
|
|
222
|
+
# -- the same "unusable value spent as evidence" that `_probability`
|
|
223
|
+
# refuses on the way in. The severity the scanner assigned is the better
|
|
224
|
+
# answer, and `severity-source` says that is what this is.
|
|
225
|
+
if math.isfinite(score) and 0.0 < score <= _CVSS_MAX:
|
|
226
|
+
return str(score), "cvss"
|
|
227
|
+
return str(_SEVERITY_FLOOR.get(vuln.severity, 0.0)), "severity-band"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _rule_for(vuln: Any, rule_id: str) -> dict[str, Any]:
|
|
231
|
+
severity_value, severity_source = _security_severity(vuln)
|
|
232
|
+
rule: dict[str, Any] = {
|
|
233
|
+
"id": rule_id,
|
|
234
|
+
"shortDescription": {"text": vuln.description or rule_id},
|
|
235
|
+
"properties": {
|
|
236
|
+
"security-severity": severity_value,
|
|
237
|
+
"severity-source": severity_source,
|
|
238
|
+
"tags": ["security", vuln.severity.value],
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
# Only link to NVD for a real advisory ID -- the bare detail URL for an
|
|
242
|
+
# empty ID is a 404 pointing nowhere.
|
|
243
|
+
if (vuln.cve_id or "").strip():
|
|
244
|
+
rule["helpUri"] = f"https://nvd.nist.gov/vuln/detail/{vuln.cve_id.strip()}"
|
|
245
|
+
return rule
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Ler `.dockerls-policy.yaml` -- e recusar o que não se entende.
|
|
2
|
+
|
|
3
|
+
Aqui mora a única diferença de comportamento importante entre este arquivo e
|
|
4
|
+
o `.dockerls-ignore.yaml`: **um arquivo de política malformado é erro, não
|
|
5
|
+
ausência de política**.
|
|
6
|
+
|
|
7
|
+
O motivo é a direção da falha. Uma regra de ignore que não carrega deixa de
|
|
8
|
+
esconder uma CVE -- o resultado é mais alarme, e alarme a mais é seguro. Uma
|
|
9
|
+
regra de política que não carrega deixa de exigir alguma coisa, e o build
|
|
10
|
+
passa parecendo ter sido conferido. Uma chave digitada errado (`require_non_root`
|
|
11
|
+
em vez de `require_nonroot`) viraria um portão aberto com cara de fechado, e
|
|
12
|
+
ninguém descobre isso olhando a saída verde.
|
|
13
|
+
|
|
14
|
+
Por isso: chave desconhecida é erro, tipo errado é erro, YAML quebrado é erro,
|
|
15
|
+
severidade inexistente é erro. Só a ausência do arquivo é silêncio -- e é
|
|
16
|
+
silêncio explícito, porque aí ninguém declarou nada.
|
|
17
|
+
|
|
18
|
+
O YAML passa pelo `safe_load_yaml`, que já recusa documentos grandes demais,
|
|
19
|
+
profundos demais ou expandidos demais antes de construir qualquer coisa: um
|
|
20
|
+
arquivo de política pode perfeitamente vir de um repositório que não é seu.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
from dockerls.domain.value_objects.build_policy import (
|
|
28
|
+
SEVERITY_ORDER,
|
|
29
|
+
BuildPolicy,
|
|
30
|
+
)
|
|
31
|
+
from dockerls.domain.value_objects.gate import GateSet, InvalidGateError
|
|
32
|
+
from dockerls.utils.safe_yaml import UnsafeYAMLError, safe_load_yaml
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
DEFAULT_POLICY_FILENAME = ".dockerls-policy.yaml"
|
|
38
|
+
|
|
39
|
+
#: As chaves aceitas. Qualquer outra é erro -- ver o docstring do módulo.
|
|
40
|
+
_KNOWN_KEYS = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"fail_on",
|
|
43
|
+
"max_vulnerabilities",
|
|
44
|
+
"require_scan",
|
|
45
|
+
"require_pinned_bases",
|
|
46
|
+
"require_nonroot",
|
|
47
|
+
"required_labels",
|
|
48
|
+
"allowed_base_registries",
|
|
49
|
+
"require_provenance",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PolicyFileError(ValueError):
|
|
55
|
+
"""O arquivo existe e não pôde ser entendido. Nunca vira política vazia."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def find_policy_file(context: Path) -> Path | None:
|
|
59
|
+
"""O arquivo de política do contexto de build, se houver um."""
|
|
60
|
+
candidate = context / DEFAULT_POLICY_FILENAME
|
|
61
|
+
return candidate if candidate.is_file() else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_policy(path: Path) -> BuildPolicy:
|
|
65
|
+
"""Carrega a política declarada, ou levanta explicando o que está errado."""
|
|
66
|
+
try:
|
|
67
|
+
raw = path.read_text(encoding="utf-8")
|
|
68
|
+
except OSError as e:
|
|
69
|
+
raise PolicyFileError(f"could not read {path}: {e}") from e
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
data = safe_load_yaml(raw, origin=str(path))
|
|
73
|
+
except UnsafeYAMLError as e:
|
|
74
|
+
raise PolicyFileError(f"{path}: {e}") from e
|
|
75
|
+
|
|
76
|
+
if data is None:
|
|
77
|
+
raise PolicyFileError(
|
|
78
|
+
f"{path} is empty. An empty policy file is almost always a mistake; "
|
|
79
|
+
"remove it if the intent is to have no policy."
|
|
80
|
+
)
|
|
81
|
+
if not isinstance(data, dict):
|
|
82
|
+
raise PolicyFileError(f"{path}: the document must be a map of rules")
|
|
83
|
+
|
|
84
|
+
desconhecidas = sorted(set(data) - _KNOWN_KEYS)
|
|
85
|
+
if desconhecidas:
|
|
86
|
+
raise PolicyFileError(
|
|
87
|
+
f"{path}: unknown rule(s): {', '.join(desconhecidas)}. "
|
|
88
|
+
f"Accepted rules are: {', '.join(sorted(_KNOWN_KEYS))}. "
|
|
89
|
+
"A mistyped key would be an open gate that looks closed."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
policy = BuildPolicy(
|
|
93
|
+
fail_on=_severity(data, "fail_on", path),
|
|
94
|
+
max_vulnerabilities=_ceilings(data, path),
|
|
95
|
+
require_scan=_flag(data, "require_scan", path),
|
|
96
|
+
require_pinned_bases=_flag(data, "require_pinned_bases", path),
|
|
97
|
+
require_nonroot=_flag(data, "require_nonroot", path),
|
|
98
|
+
required_labels=_strings(data, "required_labels", path),
|
|
99
|
+
allowed_base_registries=_strings(data, "allowed_base_registries", path),
|
|
100
|
+
require_provenance=_flag(data, "require_provenance", path),
|
|
101
|
+
)
|
|
102
|
+
if policy.is_empty:
|
|
103
|
+
raise PolicyFileError(
|
|
104
|
+
f"{path}: no rule was declared. A file that is present but demands "
|
|
105
|
+
"nothing is indistinguishable from a gate that is switched off."
|
|
106
|
+
)
|
|
107
|
+
return policy
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _flag(data: dict[str, Any], key: str, path: Path) -> bool:
|
|
111
|
+
value = data.get(key, False)
|
|
112
|
+
if not isinstance(value, bool):
|
|
113
|
+
raise PolicyFileError(f"{path}: {key} must be true or false, not {value!r}")
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _severity(data: dict[str, Any], key: str, path: Path) -> str:
|
|
118
|
+
value = data.get(key, "")
|
|
119
|
+
if not value:
|
|
120
|
+
return ""
|
|
121
|
+
# Recusar aqui é o que evita um build que morre com erro técnico no meio
|
|
122
|
+
# do caminho por causa de uma linha de YAML. `unknown` é severidade
|
|
123
|
+
# válida numa contagem e não é portão válido; `kev` e `epss>=N` são, e
|
|
124
|
+
# quem decide isso é `GateSet.parse`, para que o arquivo e a linha de
|
|
125
|
+
# comando não possam discordar sobre o que existe.
|
|
126
|
+
if not isinstance(value, str):
|
|
127
|
+
raise PolicyFileError(f"{path}: {key} must be a string, not {value!r}")
|
|
128
|
+
try:
|
|
129
|
+
GateSet.parse(value)
|
|
130
|
+
except InvalidGateError as e:
|
|
131
|
+
raise PolicyFileError(f"{path}: {key} is not a valid gate -- {e}") from e
|
|
132
|
+
return value.strip().lower()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ceilings(data: dict[str, Any], path: Path) -> dict[str, int]:
|
|
136
|
+
value = data.get("max_vulnerabilities", {})
|
|
137
|
+
if not value:
|
|
138
|
+
return {}
|
|
139
|
+
if not isinstance(value, dict):
|
|
140
|
+
raise PolicyFileError(f"{path}: max_vulnerabilities must be a map of severity to number")
|
|
141
|
+
ceilings: dict[str, int] = {}
|
|
142
|
+
for severity, limit in value.items():
|
|
143
|
+
chave = str(severity).strip().lower()
|
|
144
|
+
if chave not in SEVERITY_ORDER:
|
|
145
|
+
raise PolicyFileError(f"{path}: unknown severity in max_vulnerabilities: {severity!r}")
|
|
146
|
+
# `bool` é subclasse de `int` em Python: `high: true` passaria como
|
|
147
|
+
# teto de 1, que não é o que ninguém quis dizer.
|
|
148
|
+
if not isinstance(limit, int) or isinstance(limit, bool) or limit < 0:
|
|
149
|
+
raise PolicyFileError(
|
|
150
|
+
f"{path}: the {chave} ceiling must be an integer >= 0, not {limit!r}"
|
|
151
|
+
)
|
|
152
|
+
ceilings[chave] = limit
|
|
153
|
+
return ceilings
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _strings(data: dict[str, Any], key: str, path: Path) -> tuple[str, ...]:
|
|
157
|
+
value = data.get(key, [])
|
|
158
|
+
if not value:
|
|
159
|
+
return ()
|
|
160
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
161
|
+
raise PolicyFileError(f"{path}: {key} must be a list of strings")
|
|
162
|
+
itens = tuple(v.strip() for v in value if v.strip())
|
|
163
|
+
if not itens:
|
|
164
|
+
raise PolicyFileError(f"{path}: {key} was declared with no usable value")
|
|
165
|
+
return itens
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from pydantic_settings import (
|
|
9
|
+
BaseSettings,
|
|
10
|
+
PydanticBaseSettingsSource,
|
|
11
|
+
SettingsConfigDict,
|
|
12
|
+
TomlConfigSettingsSource,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from dockerls.domain.value_objects.scan_plan import DEFAULT_SCAN_BUDGET
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _default_cache_dir() -> Path:
|
|
19
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
20
|
+
if xdg:
|
|
21
|
+
return Path(xdg) / "dockerls"
|
|
22
|
+
return Path.home() / ".cache" / "dockerls"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _default_state_dir() -> Path:
|
|
26
|
+
xdg = os.environ.get("XDG_STATE_HOME")
|
|
27
|
+
if xdg:
|
|
28
|
+
return Path(xdg) / "dockerls"
|
|
29
|
+
return Path.home() / ".local" / "state" / "dockerls"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _default_log_dir() -> Path:
|
|
33
|
+
return _default_state_dir() / "logs"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _default_evidence_dir() -> Path:
|
|
37
|
+
return _default_state_dir() / "scans"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _default_config_path() -> Path:
|
|
41
|
+
"""~/.config/dockerls/config.toml (or $XDG_CONFIG_HOME/dockerls/config.toml)."""
|
|
42
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
|
43
|
+
base = Path(xdg_config) if xdg_config else Path.home() / ".config"
|
|
44
|
+
return base / "dockerls" / "config.toml"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Settings(BaseSettings):
|
|
48
|
+
"""Configuration resolved, highest priority first, from: constructor
|
|
49
|
+
kwargs -> environment variables -> ~/.config/dockerls/config.toml ->
|
|
50
|
+
field defaults. DOCKERHUB_USERNAME and DOCKERHUB_TOKEN keep
|
|
51
|
+
their historical unprefixed env var names for backward compatibility;
|
|
52
|
+
every other setting is DOCKERLS_<FIELD_NAME>.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
model_config = SettingsConfigDict(
|
|
56
|
+
env_prefix="DOCKERLS_",
|
|
57
|
+
toml_file=_default_config_path(),
|
|
58
|
+
extra="ignore",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
cache_dir: Path = Field(default_factory=_default_cache_dir)
|
|
62
|
+
cache_ttl_seconds: int = 86400
|
|
63
|
+
# Tag existence is cached separately and more briefly: a tag
|
|
64
|
+
# disappearing matters sooner than a score going slightly stale.
|
|
65
|
+
tag_cache_ttl_seconds: int = 6 * 3600
|
|
66
|
+
max_tags: int = 100
|
|
67
|
+
# Quantas das tags descobertas este run realmente mede. `max_tags`
|
|
68
|
+
# governa a *descoberta*; isto governa a *medição*, e são coisas
|
|
69
|
+
# diferentes: descobrir 100 tags custa uma chamada HTTP, medir as 100
|
|
70
|
+
# custa dois a quatro minutos de Trivy para exibir cinco.
|
|
71
|
+
#
|
|
72
|
+
# O corte não esconde nada. As tags não medidas voltam no resultado
|
|
73
|
+
# (`deferred`) com o motivo -- quase sempre "existe uma tag mais nova
|
|
74
|
+
# da mesma linha" --, porque uma tag não medida não é uma tag pior.
|
|
75
|
+
# `0` mede todas, que é o comportamento anterior.
|
|
76
|
+
scan_budget: int = DEFAULT_SCAN_BUDGET
|
|
77
|
+
# 0 means "derive from this machine": each worker holds a scanner
|
|
78
|
+
# process that wants a core and hundreds of megabytes, so a flat number
|
|
79
|
+
# oversubscribes small runners and underuses large ones. Any explicit
|
|
80
|
+
# value is honoured as given -- the operator knows their machine.
|
|
81
|
+
workers: int = 0
|
|
82
|
+
max_critical: int = 0
|
|
83
|
+
max_high: int = 0
|
|
84
|
+
max_medium: int = 5
|
|
85
|
+
dockerhub_username: str = Field(default="", validation_alias="DOCKERHUB_USERNAME")
|
|
86
|
+
dockerhub_token: str = Field(default="", validation_alias="DOCKERHUB_TOKEN")
|
|
87
|
+
log_level: str = "INFO"
|
|
88
|
+
# Diagnostics go here, never to the terminal (see setup_logging).
|
|
89
|
+
log_dir: Path = Field(default_factory=_default_log_dir)
|
|
90
|
+
# Raw scanner JSON, kept so every displayed score is auditable.
|
|
91
|
+
evidence_dir: Path = Field(default_factory=_default_evidence_dir)
|
|
92
|
+
# Trivy's own cache root; the per-worker cache pool is built next to it.
|
|
93
|
+
trivy_cache_dir: Path | None = None
|
|
94
|
+
# Re-scan the top candidates with the secondary scanner and flag
|
|
95
|
+
# material disagreements instead of showing an undisputed score.
|
|
96
|
+
cross_validate: bool = True
|
|
97
|
+
# Confirm each recommended tag really exists on Docker Hub.
|
|
98
|
+
verify_hub_tags: bool = True
|
|
99
|
+
# Concurrent secondary scans during cross-validation. 0 means "derive
|
|
100
|
+
# from this machine", like `workers`: these are scanner processes too,
|
|
101
|
+
# and five of them on a two-core runner contend for exactly the same
|
|
102
|
+
# cores the primary scan just finished using.
|
|
103
|
+
cross_validate_workers: int = 0
|
|
104
|
+
# Search free hardened catalogues (Chainguard, Distroless) alongside
|
|
105
|
+
# Docker Hub, so a hardened image can win on measured vulnerabilities.
|
|
106
|
+
include_hardened_sources: bool = True
|
|
107
|
+
# Tags pulled per hardened source; these catalogues are small and their
|
|
108
|
+
# listings are unordered, so a wide fetch buys nothing.
|
|
109
|
+
hardened_tag_limit: int = 10
|
|
110
|
+
# Docker Hardened Images. Off by default because dhi.io refuses
|
|
111
|
+
# anonymous pulls: without credentials its candidates cannot be scanned,
|
|
112
|
+
# and an unscannable candidate is reported UNVERIFIED rather than
|
|
113
|
+
# ranked. `--source dhi` turns it on for a single run regardless.
|
|
114
|
+
include_dhi_source: bool = False
|
|
115
|
+
# How long the DHI catalogue index stays usable before it is refetched.
|
|
116
|
+
# The catalogue moves a few times a day; six hours keeps discovery
|
|
117
|
+
# current while costing one GitHub API request per window.
|
|
118
|
+
dhi_catalog_ttl_seconds: int = 6 * 3600
|
|
119
|
+
# Definition files read per DHI query. Each is one CDN request, and a
|
|
120
|
+
# popular image has dozens across OS variants and build flavours.
|
|
121
|
+
dhi_definition_limit: int = 12
|
|
122
|
+
# Raises GitHub's anonymous 60-requests/hour ceiling for catalogue
|
|
123
|
+
# refreshes. Read-only public data: no scope is required.
|
|
124
|
+
github_token: str = Field(default="", validation_alias="DOCKERLS_GITHUB_TOKEN")
|
|
125
|
+
# Resolve every unpinned tag to a manifest digest before scanning. This
|
|
126
|
+
# is what makes deduplication work across sources: without it, tags that
|
|
127
|
+
# share a manifest are scanned once each.
|
|
128
|
+
resolve_digests: bool = True
|
|
129
|
+
# Fetch the OCI config of each finalist to measure how it is configured
|
|
130
|
+
# (non-root, ports, entrypoint) instead of relying on vendor claims.
|
|
131
|
+
inspect_image_config: bool = True
|
|
132
|
+
# Where an image reference is allowed to make this process connect. A
|
|
133
|
+
# reference is user input, so without these a crafted name reaches the
|
|
134
|
+
# cloud metadata endpoint or a service on the runner. Private ranges are
|
|
135
|
+
# allowed by default because internal registries are ordinary; loopback
|
|
136
|
+
# and link-local are not, because that is the actual attack.
|
|
137
|
+
network_allow_private_networks: bool = True
|
|
138
|
+
network_allow_loopback: bool = False
|
|
139
|
+
network_allow_link_local: bool = False
|
|
140
|
+
#: Hosts permitted regardless of where they resolve ("registry:5000").
|
|
141
|
+
network_allowed_hosts: list[str] = Field(default_factory=list)
|
|
142
|
+
scanner_timeout: int = 300
|
|
143
|
+
http_timeout: int = 30
|
|
144
|
+
retry_max_attempts: int = 3
|
|
145
|
+
retry_backoff_base: float = 2.0
|
|
146
|
+
enable_threat_intel: bool = True
|
|
147
|
+
# A private/organization registry: host, optional namespace prefix, and
|
|
148
|
+
# Basic credentials exchanged for a bearer token at the standard Docker
|
|
149
|
+
# Registry HTTP API V2 token endpoint -- the same protocol ECR, Harbor,
|
|
150
|
+
# GHCR's container registry and a self-hosted `registry:2` all speak.
|
|
151
|
+
# Off by default (empty host): a source nobody configured must not
|
|
152
|
+
# appear as an option, the same reasoning `include_dhi_source` follows
|
|
153
|
+
# for a source that needs credentials to be useful.
|
|
154
|
+
private_registry_host: str = ""
|
|
155
|
+
private_registry_namespace: str = ""
|
|
156
|
+
private_registry_username: str = Field(
|
|
157
|
+
default="", validation_alias="DOCKERLS_PRIVATE_REGISTRY_USERNAME"
|
|
158
|
+
)
|
|
159
|
+
private_registry_password: str = Field(
|
|
160
|
+
default="", validation_alias="DOCKERLS_PRIVATE_REGISTRY_PASSWORD"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def settings_customise_sources(
|
|
165
|
+
cls,
|
|
166
|
+
settings_cls: type[BaseSettings],
|
|
167
|
+
init_settings: PydanticBaseSettingsSource,
|
|
168
|
+
env_settings: PydanticBaseSettingsSource,
|
|
169
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
170
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
171
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
172
|
+
return (
|
|
173
|
+
init_settings,
|
|
174
|
+
env_settings,
|
|
175
|
+
dotenv_settings,
|
|
176
|
+
TomlConfigSettingsSource(settings_cls),
|
|
177
|
+
file_secret_settings,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def model_post_init(self, __context: object) -> None:
|
|
181
|
+
# Legacy opt-out flag from before the DOCKERLS_ env prefix was
|
|
182
|
+
# introduced; keep honoring it alongside DOCKERLS_ENABLE_THREAT_INTEL.
|
|
183
|
+
if os.environ.get("DOCKERLS_DISABLE_THREAT_INTEL"):
|
|
184
|
+
self.enable_threat_intel = False
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def db_path(self) -> Path:
|
|
188
|
+
return self.cache_dir / "cache.db"
|
|
189
|
+
|
|
190
|
+
def ensure_dirs(self) -> None:
|
|
191
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
# Log and evidence dirs are best-effort: a read-only working
|
|
193
|
+
# directory must degrade (setup_logging falls back to the cache dir,
|
|
194
|
+
# evidence recording is skipped) rather than abort the command.
|
|
195
|
+
for path in (self.log_dir, self.evidence_dir):
|
|
196
|
+
with contextlib.suppress(OSError):
|
|
197
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
File without changes
|