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,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from dockerls.domain.entities.vulnerability import Severity, Vulnerability
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ScanStatus(StrEnum):
|
|
11
|
+
OK = "OK"
|
|
12
|
+
ERROR = "ERROR"
|
|
13
|
+
TIMEOUT = "TIMEOUT"
|
|
14
|
+
PARTIAL = "PARTIAL"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ScanErrorKind(StrEnum):
|
|
18
|
+
"""Why a scan failed, as a stable code rather than a slice of stderr.
|
|
19
|
+
|
|
20
|
+
A terminal column can only hold a few characters, and truncating the raw
|
|
21
|
+
message produced things like `error in v...` -- which names no cause and
|
|
22
|
+
cannot be grouped, counted, or acted on. The full stderr is still kept in
|
|
23
|
+
`error_message` (log file and `--format json`); this is what the table
|
|
24
|
+
shows instead of a cut-off prefix.
|
|
25
|
+
|
|
26
|
+
It also decides *retryability*: a broken vulnerability database is the
|
|
27
|
+
scanner's problem and another scanner may well succeed, while a tag that
|
|
28
|
+
does not exist will fail identically no matter who is asked.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
NONE = "NONE"
|
|
32
|
+
DB_INIT_FAILED = "DB_INIT_FAILED"
|
|
33
|
+
TIMEOUT = "TIMEOUT"
|
|
34
|
+
AUTH_REQUIRED = "AUTH_REQUIRED"
|
|
35
|
+
NOT_FOUND = "NOT_FOUND"
|
|
36
|
+
RATE_LIMITED = "RATE_LIMITED"
|
|
37
|
+
INVALID_OUTPUT = "INVALID_OUTPUT"
|
|
38
|
+
SCANNER_MISSING = "SCANNER_MISSING"
|
|
39
|
+
#: The reference named a host the network policy refuses to contact.
|
|
40
|
+
#: The scan never ran, so this is an absence of measurement like any
|
|
41
|
+
#: other error -- and it is *not* a scanner fault: a second tool would
|
|
42
|
+
#: pull from exactly the same refused host.
|
|
43
|
+
BLOCKED_BY_POLICY = "BLOCKED_BY_POLICY"
|
|
44
|
+
UNKNOWN = "UNKNOWN"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_scanner_fault(self) -> bool:
|
|
48
|
+
"""True when a *different* scanner has a real chance of succeeding.
|
|
49
|
+
|
|
50
|
+
`NOT_FOUND` and `AUTH_REQUIRED` are facts about the image, so retrying
|
|
51
|
+
them with another tool just doubles the wait for the same answer.
|
|
52
|
+
"""
|
|
53
|
+
return self in (
|
|
54
|
+
ScanErrorKind.DB_INIT_FAILED,
|
|
55
|
+
ScanErrorKind.TIMEOUT,
|
|
56
|
+
ScanErrorKind.RATE_LIMITED,
|
|
57
|
+
ScanErrorKind.INVALID_OUTPUT,
|
|
58
|
+
ScanErrorKind.SCANNER_MISSING,
|
|
59
|
+
ScanErrorKind.UNKNOWN,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ScanResult(BaseModel):
|
|
64
|
+
image_reference: str
|
|
65
|
+
scanner: str = "trivy"
|
|
66
|
+
vulnerabilities: list[Vulnerability] = []
|
|
67
|
+
scan_timestamp: str = ""
|
|
68
|
+
status: ScanStatus = ScanStatus.OK
|
|
69
|
+
error_message: str = ""
|
|
70
|
+
# Classified reason for a failure. `error_message` keeps the full stderr;
|
|
71
|
+
# this is the part that fits in a table and can be grouped across tags.
|
|
72
|
+
error_kind: ScanErrorKind = ScanErrorKind.NONE
|
|
73
|
+
# Path to the raw scanner JSON this result was parsed from, so a score
|
|
74
|
+
# shown to the user can always be traced back to its source evidence.
|
|
75
|
+
evidence_path: str = ""
|
|
76
|
+
# Base distribution the scanner identified ("debian", "alpine", "wolfi")
|
|
77
|
+
# and its version. This is the only *measured* source of the image's
|
|
78
|
+
# libc family, which is what decides whether a migration between two
|
|
79
|
+
# images can work at all -- an Alpine-to-Debian move swaps musl for
|
|
80
|
+
# glibc, and native modules compiled against one do not load under the
|
|
81
|
+
# other. Empty when the scanner did not report it.
|
|
82
|
+
os_family: str = ""
|
|
83
|
+
os_version: str = ""
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def is_verified(self) -> bool:
|
|
87
|
+
"""A scan that actually completed and produced a parsed result.
|
|
88
|
+
|
|
89
|
+
This is the gate for recommending an image: only a scanner run that
|
|
90
|
+
exited cleanly (`OK`) and carries a timestamp counts as proof. A
|
|
91
|
+
`PARTIAL` scan is usable for reporting but is deliberately *not*
|
|
92
|
+
verified -- it means some targets could not be inspected, so its
|
|
93
|
+
vulnerability counts are a lower bound, not a measurement.
|
|
94
|
+
"""
|
|
95
|
+
return self.status is ScanStatus.OK and bool(self.scan_timestamp)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def critical_count(self) -> int:
|
|
99
|
+
return sum(1 for v in self.vulnerabilities if v.severity == Severity.CRITICAL)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def high_count(self) -> int:
|
|
103
|
+
return sum(1 for v in self.vulnerabilities if v.severity == Severity.HIGH)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def medium_count(self) -> int:
|
|
107
|
+
return sum(1 for v in self.vulnerabilities if v.severity == Severity.MEDIUM)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def low_count(self) -> int:
|
|
111
|
+
return sum(1 for v in self.vulnerabilities if v.severity == Severity.LOW)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def total_count(self) -> int:
|
|
115
|
+
return len(self.vulnerabilities)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def fixable_count(self) -> int:
|
|
119
|
+
return sum(1 for v in self.vulnerabilities if v.is_fixable)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def fixable_high_count(self) -> int:
|
|
123
|
+
return sum(1 for v in self.vulnerabilities if v.severity == Severity.HIGH and v.is_fixable)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def fixable_critical_count(self) -> int:
|
|
127
|
+
return sum(
|
|
128
|
+
1 for v in self.vulnerabilities if v.severity == Severity.CRITICAL and v.is_fixable
|
|
129
|
+
)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _probability(value: Any, field: str) -> float:
|
|
14
|
+
"""Coerce an externally supplied probability into 0.0-1.0, or to zero.
|
|
15
|
+
|
|
16
|
+
EPSS arrives as JSON from a third party, and `float()` happily accepts
|
|
17
|
+
`"nan"`, `"inf"` and `"-1"`. None of the three is a probability, and all
|
|
18
|
+
three used to reach the scoring engine, where the damage was not
|
|
19
|
+
cosmetic:
|
|
20
|
+
|
|
21
|
+
* `NaN` propagates through the penalty sum, and the final
|
|
22
|
+
`max(0.0, min(100.0, score))` answers **100.0** for a NaN input --
|
|
23
|
+
every comparison against NaN is False, so the clamp keeps its own
|
|
24
|
+
bound. A malformed feed therefore certified an image as perfect.
|
|
25
|
+
* A negative probability *subtracts* from the penalty, so a feed
|
|
26
|
+
answering `-5` raises the score of an image with more findings.
|
|
27
|
+
|
|
28
|
+
Both are the same failure this codebase is built to refuse: a source
|
|
29
|
+
that did not give a usable answer being spent as evidence of safety. An
|
|
30
|
+
unusable value becomes 0.0, which contributes nothing in either
|
|
31
|
+
direction, and is loud in the log rather than silent.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
number = float(value)
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
number = math.nan
|
|
37
|
+
if not math.isfinite(number) or not (0.0 <= number <= 1.0):
|
|
38
|
+
logger.warning(f"Discarding out-of-range {field} value {value!r}; treated as no signal")
|
|
39
|
+
return 0.0
|
|
40
|
+
return number
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Severity(StrEnum):
|
|
44
|
+
CRITICAL = "CRITICAL"
|
|
45
|
+
HIGH = "HIGH"
|
|
46
|
+
MEDIUM = "MEDIUM"
|
|
47
|
+
LOW = "LOW"
|
|
48
|
+
UNKNOWN = "UNKNOWN"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FixStatus(StrEnum):
|
|
52
|
+
FIX_AVAILABLE = "FIX_AVAILABLE"
|
|
53
|
+
NO_FIX = "NO_FIX"
|
|
54
|
+
UNDER_INVESTIGATION = "UNDER_INVESTIGATION"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PackageOrigin(StrEnum):
|
|
58
|
+
"""De onde vem o pacote afetado -- o que determina como se remedia."""
|
|
59
|
+
|
|
60
|
+
OS = "os"
|
|
61
|
+
LANGUAGE = "lang-pkg"
|
|
62
|
+
UNKNOWN = "unknown"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Marcadores vindos do `Class`/`Type`/`Target` do Trivy e do `artifact.type`
|
|
66
|
+
# do Grype. Os de linguagem são checados primeiro: um `Target` como
|
|
67
|
+
# "/usr/local/lib/node_modules/npm/package-lock.json" contém tanto "npm"
|
|
68
|
+
# quanto, potencialmente, o nome da distro.
|
|
69
|
+
_LANG_MARKERS = (
|
|
70
|
+
"lang-pkg",
|
|
71
|
+
"node-pkg",
|
|
72
|
+
"npm",
|
|
73
|
+
"yarn",
|
|
74
|
+
"python-pkg",
|
|
75
|
+
"pip",
|
|
76
|
+
"poetry",
|
|
77
|
+
"gemspec",
|
|
78
|
+
"gem",
|
|
79
|
+
"cargo",
|
|
80
|
+
"composer",
|
|
81
|
+
"go-module",
|
|
82
|
+
"gobinary",
|
|
83
|
+
"jar",
|
|
84
|
+
"pom",
|
|
85
|
+
"conan",
|
|
86
|
+
"nuget",
|
|
87
|
+
"swift",
|
|
88
|
+
"pub",
|
|
89
|
+
"hex",
|
|
90
|
+
"site-packages",
|
|
91
|
+
"node_modules",
|
|
92
|
+
)
|
|
93
|
+
_OS_MARKERS = (
|
|
94
|
+
"os-pkg",
|
|
95
|
+
"apk",
|
|
96
|
+
"deb",
|
|
97
|
+
"rpm",
|
|
98
|
+
"alpine",
|
|
99
|
+
"debian",
|
|
100
|
+
"ubuntu",
|
|
101
|
+
"redhat",
|
|
102
|
+
"centos",
|
|
103
|
+
"amazon",
|
|
104
|
+
"suse",
|
|
105
|
+
"photon",
|
|
106
|
+
"oracle",
|
|
107
|
+
"rocky",
|
|
108
|
+
"alma",
|
|
109
|
+
"wolfi",
|
|
110
|
+
"chainguard",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Vulnerability(BaseModel):
|
|
115
|
+
cve_id: str
|
|
116
|
+
severity: Severity
|
|
117
|
+
cvss_score: float = 0.0
|
|
118
|
+
# Qual base publicou o `cvss_score` acima ("nvd", "redhat", "ghsa", ...).
|
|
119
|
+
# Severidade e score podem vir de bases diferentes -- é assim que um
|
|
120
|
+
# CRITICAL aparecia ao lado de um CVSS 7.5 e parecia contradição. Dizer a
|
|
121
|
+
# origem é o que torna o número verificável em vez de suspeito.
|
|
122
|
+
cvss_source: str = ""
|
|
123
|
+
# Origem do pacote: "os" (apk/apt/yum) ou "lang-pkg" (npm, pip, gem...).
|
|
124
|
+
# Decide a remediação: `apk upgrade` não resolve um CVE de dependência do
|
|
125
|
+
# npm embutido na imagem.
|
|
126
|
+
package_type: str = ""
|
|
127
|
+
target: str = ""
|
|
128
|
+
package_name: str = ""
|
|
129
|
+
installed_version: str = ""
|
|
130
|
+
fixed_version: str = ""
|
|
131
|
+
description: str = ""
|
|
132
|
+
published_date: str = ""
|
|
133
|
+
# Confirmed exploited in the wild (CISA KEV). Kept as the boolean the
|
|
134
|
+
# scoring engine and the exporters have always read; `kev_status` is the
|
|
135
|
+
# field that distinguishes "checked, not listed" from "never checked".
|
|
136
|
+
exploit_known: bool = False
|
|
137
|
+
# Three-valued, because a threat-intel feed that did not answer must not
|
|
138
|
+
# look like a feed that answered "no". With the KEV catalogue
|
|
139
|
+
# unreachable every CVE came back `exploit_known=False`, and the report
|
|
140
|
+
# then stated -- affirmatively -- that the image had no known-exploited
|
|
141
|
+
# vulnerabilities. That sentence is the strongest claim this tool makes
|
|
142
|
+
# about real-world exploitation, and it was being emitted precisely when
|
|
143
|
+
# nothing had been consulted.
|
|
144
|
+
kev_status: Tristate = Tristate.UNKNOWN
|
|
145
|
+
# EPSS probability, 0.0-1.0. `epss_known` says whether the 0.0 means
|
|
146
|
+
# "FIRST.org scored it at zero" or "FIRST.org was not reachable".
|
|
147
|
+
epss_score: float = 0.0
|
|
148
|
+
epss_known: bool = False
|
|
149
|
+
#: EPSS percentile when the source provides it: 0.9 probability is
|
|
150
|
+
#: extreme, while the percentile says how extreme relative to everything
|
|
151
|
+
#: else scored that day.
|
|
152
|
+
epss_percentile: float = 0.0
|
|
153
|
+
#: When the intelligence above was retrieved, so a stale enrichment is
|
|
154
|
+
#: visible rather than assumed current.
|
|
155
|
+
threat_intel_timestamp: str = ""
|
|
156
|
+
# Public exploit catalogued in Exploit-DB. Three-valued for the same
|
|
157
|
+
# reason `kev_status` is: a catalogue that was never consulted must not
|
|
158
|
+
# read as a catalogue that answered "no exploit". UNKNOWN is the default
|
|
159
|
+
# precisely so an absent lookup can never be spent as reassurance.
|
|
160
|
+
#
|
|
161
|
+
# KEV and this answer different questions and neither replaces the
|
|
162
|
+
# other: KEV means "observed being exploited in the wild", while an
|
|
163
|
+
# Exploit-DB entry means "working exploit code is published". A CVE can
|
|
164
|
+
# sit in one and not the other.
|
|
165
|
+
exploitdb_status: Tristate = Tristate.UNKNOWN
|
|
166
|
+
#: The EDB-ID(s) matched, so a reader can open
|
|
167
|
+
#: https://www.exploit-db.com/exploits/<id> and judge the exploit itself.
|
|
168
|
+
#: Matching is strictly CVE-ID to CVE-ID -- see the client for why a
|
|
169
|
+
#: product-name fallback would manufacture false positives.
|
|
170
|
+
exploitdb_ids: list[str] = Field(default_factory=list)
|
|
171
|
+
#: Exploit-DB flags entries as verified (someone reproduced it) or not.
|
|
172
|
+
#: An unverified entry is a weaker signal than a verified one, and
|
|
173
|
+
#: collapsing the two would overstate what is known. Only meaningful
|
|
174
|
+
#: when `exploitdb_status` is TRUE.
|
|
175
|
+
exploitdb_verified: bool = False
|
|
176
|
+
|
|
177
|
+
@field_validator("epss_score", "epss_percentile", mode="before")
|
|
178
|
+
@classmethod
|
|
179
|
+
def _bound_probabilities(cls, value: Any, info: Any) -> float:
|
|
180
|
+
"""Enforced on the entity, not only where the feed is parsed.
|
|
181
|
+
|
|
182
|
+
The same field is rebuilt from the SQLite cache and from scanner
|
|
183
|
+
JSON, and a row written before this check existed is still on disk.
|
|
184
|
+
The invariant belongs to the type.
|
|
185
|
+
"""
|
|
186
|
+
return _probability(value, info.field_name)
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def origin(self) -> PackageOrigin:
|
|
190
|
+
"""Whether this finding lives in an OS package or a language package.
|
|
191
|
+
|
|
192
|
+
Decide a remediação, e a diferença é total: as vulnerabilidades de
|
|
193
|
+
`node:22-alpine` estão todas em
|
|
194
|
+
`/usr/local/lib/node_modules/npm/node_modules/`, isto é, nas
|
|
195
|
+
dependências do npm que a imagem embute. `apk upgrade` não resolve
|
|
196
|
+
nenhuma delas. Sem essa distinção, a recomendação aponta para o
|
|
197
|
+
gerenciador errado.
|
|
198
|
+
"""
|
|
199
|
+
haystack = f"{self.package_type} {self.target}".lower()
|
|
200
|
+
if any(marker in haystack for marker in _LANG_MARKERS):
|
|
201
|
+
return PackageOrigin.LANGUAGE
|
|
202
|
+
if any(marker in haystack for marker in _OS_MARKERS):
|
|
203
|
+
return PackageOrigin.OS
|
|
204
|
+
return PackageOrigin.UNKNOWN
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def fix_status(self) -> FixStatus:
|
|
208
|
+
if self.fixed_version:
|
|
209
|
+
return FixStatus.FIX_AVAILABLE
|
|
210
|
+
return FixStatus.NO_FIX
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def is_fixable(self) -> bool:
|
|
214
|
+
return self.fix_status == FixStatus.FIX_AVAILABLE
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def finding_identity(vuln: Vulnerability) -> str:
|
|
218
|
+
"""Uma identidade estável para um achado, comparável entre medições.
|
|
219
|
+
|
|
220
|
+
Id da vulnerabilidade **e** pacote, porque o mesmo CVE afetando dois
|
|
221
|
+
pacotes de uma imagem são dois problemas a resolver, e o mesmo pacote
|
|
222
|
+
carregando dois CVEs também. A versão instalada fica de fora de propósito:
|
|
223
|
+
dois scanners -- e a mesma imagem antes e depois de um build -- reportam o
|
|
224
|
+
mesmo pacote com strings de versão normalizadas de formas diferentes, e
|
|
225
|
+
comparar isso fabricaria diferença a partir de formatação.
|
|
226
|
+
|
|
227
|
+
Mora aqui, e não no serviço que a inventou, porque duas coisas distintas
|
|
228
|
+
dependem dela: a validação cruzada entre scanners e a atribuição de cada
|
|
229
|
+
achado à base ou às suas camadas. Se cada uma tivesse a sua, as duas
|
|
230
|
+
divergiriam na primeira correção.
|
|
231
|
+
"""
|
|
232
|
+
return f"{vuln.cve_id.strip().upper()}|{vuln.package_name.strip().lower()}"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from dockerls.domain.interfaces.cache_store import CacheStoreInterface
|
|
2
|
+
from dockerls.domain.interfaces.dockerfile_validator import (
|
|
3
|
+
DockerfileValidatorInterface,
|
|
4
|
+
HardeningTemplateProvider,
|
|
5
|
+
)
|
|
6
|
+
from dockerls.domain.interfaces.eol_checker import EOLCheckerInterface
|
|
7
|
+
from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
|
|
8
|
+
from dockerls.domain.interfaces.scanner import ScannerInterface
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ImageRepositoryInterface",
|
|
12
|
+
"ScannerInterface",
|
|
13
|
+
"EOLCheckerInterface",
|
|
14
|
+
"CacheStoreInterface",
|
|
15
|
+
"DockerfileValidatorInterface",
|
|
16
|
+
"HardeningTemplateProvider",
|
|
17
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CacheStoreInterface(ABC):
|
|
8
|
+
@abstractmethod
|
|
9
|
+
async def get(self, key: str) -> Any | None: ...
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def set(self, key: str, value: Any, ttl_seconds: int = 86400) -> None: ...
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def delete(self, key: str) -> None: ...
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
async def clear(self) -> None: ...
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Interfaces para validação de Dockerfiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from dockerls.domain.entities.dockerfile_analysis import (
|
|
12
|
+
DockerfileAnalysis,
|
|
13
|
+
DockerfileValidationResult,
|
|
14
|
+
HardeningRule,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DockerfileValidatorInterface(ABC):
|
|
19
|
+
"""Interface para validadores de Dockerfile."""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def validate(self, dockerfile_path: str | Path) -> DockerfileValidationResult:
|
|
23
|
+
"""Valida um Dockerfile contra regras OWASP.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
dockerfile_path: Caminho para o Dockerfile ou diretório contendo Dockerfile.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Resultado da validação com checks, warnings e errors.
|
|
30
|
+
"""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def analyze(self, dockerfile_path: str | Path) -> DockerfileAnalysis:
|
|
35
|
+
"""Analisa um Dockerfile e retorna análise completa.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
dockerfile_path: Caminho para o Dockerfile ou diretório contendo Dockerfile.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Análise completa incluindo info, validação e score de segurança.
|
|
42
|
+
"""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def suggest_hardening(self, dockerfile_path: str | Path) -> list[HardeningRule]:
|
|
47
|
+
"""Sugere melhorias de hardening para um Dockerfile.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
dockerfile_path: Caminho para o Dockerfile ou diretório contendo Dockerfile.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Lista de regras de hardening sugeridas.
|
|
54
|
+
"""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class HardeningTemplateProvider(ABC):
|
|
59
|
+
"""Interface para provedores de templates hardened."""
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def get_template(self, base_image: str) -> str:
|
|
63
|
+
"""Retorna template hardened para um tipo de base image.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
base_image: Nome da imagem base (ex: node, python, go).
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Conteúdo do template Dockerfile hardened.
|
|
70
|
+
"""
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def list_templates(self) -> list[str]:
|
|
75
|
+
"""Lista todos os templates disponíveis.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Lista de nomes de templates disponíveis.
|
|
79
|
+
"""
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def generate_hardened_dockerfile(
|
|
84
|
+
self,
|
|
85
|
+
dockerfile_path: str | Path,
|
|
86
|
+
base_image: str | None = None,
|
|
87
|
+
output_path: str | Path | None = None,
|
|
88
|
+
) -> str:
|
|
89
|
+
"""Gera um Dockerfile hardened baseado no original ou template.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
dockerfile_path: Caminho para o Dockerfile original.
|
|
93
|
+
base_image: Imagem base para usar no template (opcional).
|
|
94
|
+
output_path: Caminho para salvar o novo Dockerfile (opcional).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Conteúdo do Dockerfile hardened gerado.
|
|
98
|
+
"""
|
|
99
|
+
pass
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EOLCheckerInterface(ABC):
|
|
7
|
+
@abstractmethod
|
|
8
|
+
async def is_eol(self, product: str, version: str) -> bool: ...
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
async def is_lts(self, product: str, version: str) -> bool: ...
|
|
@@ -0,0 +1,15 @@
|
|
|
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 dockerls.domain.entities.image import DockerImage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ImageRepositoryInterface(ABC):
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def search_tags(self, image_name: str, limit: int = 100) -> list[DockerImage]: ...
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def get_image_metadata(self, image_name: str, tag: str) -> DockerImage | None: ...
|
|
@@ -0,0 +1,15 @@
|
|
|
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 dockerls.domain.entities.scan_result import ScanResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ScannerInterface(ABC):
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def scan(self, image_reference: str) -> ScanResult: ...
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def is_available(self) -> bool: ...
|