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,362 @@
|
|
|
1
|
+
"""Published security controls, and which of this tool's rules implement them.
|
|
2
|
+
|
|
3
|
+
`analyze-dockerfile` has always answered with a code: `DF002 failed`. That
|
|
4
|
+
code means nothing outside this repository. A reader who wants to know
|
|
5
|
+
whether the rule is a real requirement or one maintainer's preference has
|
|
6
|
+
nowhere to look, and an auditor who needs to map findings onto a compliance
|
|
7
|
+
framework has to do it by hand, from the message text.
|
|
8
|
+
|
|
9
|
+
This module closes that gap by naming, for each rule, the published control
|
|
10
|
+
it implements. The value is not decoration: a finding that cites *CIS Docker
|
|
11
|
+
Benchmark 4.1* can be argued about, escalated, waived with a reason, and
|
|
12
|
+
mapped to an audit programme. A finding that cites `DF002` can only be
|
|
13
|
+
obeyed or ignored.
|
|
14
|
+
|
|
15
|
+
**Every identifier and title here was verified against its primary source**
|
|
16
|
+
rather than recalled, on 2026-08-18:
|
|
17
|
+
|
|
18
|
+
* **CIS Docker Benchmark, section 4** -- checked against Docker's own
|
|
19
|
+
implementation of the benchmark, `docker/docker-bench-security`
|
|
20
|
+
(`tests/4_container_images.sh`), which carries the control numbers and
|
|
21
|
+
titles verbatim.
|
|
22
|
+
* **OWASP Docker Security Cheat Sheet** -- checked against the published
|
|
23
|
+
cheat sheet at cheatsheetseries.owasp.org, whose rules are numbered
|
|
24
|
+
`RULE #0` through `RULE #13`.
|
|
25
|
+
* **NIST SP 800-190** -- checked against the table of contents of the
|
|
26
|
+
official NIST publication.
|
|
27
|
+
|
|
28
|
+
That verification is the point, and it changed the content: three of the
|
|
29
|
+
four citations drafted from memory were wrong. `NIST SP 800-190 4.4.2` is
|
|
30
|
+
*Unbounded network access from containers*, not "least privilege", and
|
|
31
|
+
`OWASP RULE #8` is *Set filesystem and volumes to read-only*, not "minimal
|
|
32
|
+
base images". A tool that refuses to state a vulnerability count it did not
|
|
33
|
+
measure cannot cite a control it did not check either.
|
|
34
|
+
|
|
35
|
+
Where no published control covers a rule, that is stated rather than
|
|
36
|
+
stretched: `controls_for` returns an empty tuple, and the renderers say the
|
|
37
|
+
rule is this project's own guidance. Inventing a plausible-looking control
|
|
38
|
+
number would be worse than having none, because it is the kind of error that
|
|
39
|
+
survives review.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from enum import StrEnum
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ControlSource(StrEnum):
|
|
49
|
+
"""Where a control is published. Cited by name so a reader can find it."""
|
|
50
|
+
|
|
51
|
+
CIS_DOCKER = "CIS Docker Benchmark"
|
|
52
|
+
NIST_800_190 = "NIST SP 800-190"
|
|
53
|
+
OWASP_DOCKER = "OWASP Docker Security Cheat Sheet"
|
|
54
|
+
DOCKER_DOCS = "Docker documentation"
|
|
55
|
+
OCI_SPEC = "OCI Image Format Specification"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Control:
|
|
60
|
+
"""One published control, quoted rather than paraphrased.
|
|
61
|
+
|
|
62
|
+
`title` is the source's own wording. Paraphrasing would make the
|
|
63
|
+
citation unsearchable, which defeats the purpose of having one.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
source: ControlSource
|
|
67
|
+
#: The source's identifier: "4.1", "RULE #2", "4.1.2". Empty for
|
|
68
|
+
#: documentation that is cited by page rather than by number.
|
|
69
|
+
identifier: str
|
|
70
|
+
title: str
|
|
71
|
+
|
|
72
|
+
def __str__(self) -> str:
|
|
73
|
+
head = f"{self.source.value} {self.identifier}".strip()
|
|
74
|
+
return f"{head} -- {self.title}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- The catalogue, verified against primary sources ---------------------
|
|
78
|
+
|
|
79
|
+
CIS_4_1 = Control(
|
|
80
|
+
ControlSource.CIS_DOCKER, "4.1", "Ensure that a user for the container has been created"
|
|
81
|
+
)
|
|
82
|
+
CIS_4_2 = Control(
|
|
83
|
+
ControlSource.CIS_DOCKER, "4.2", "Ensure that containers use only trusted base images"
|
|
84
|
+
)
|
|
85
|
+
CIS_4_3 = Control(
|
|
86
|
+
ControlSource.CIS_DOCKER,
|
|
87
|
+
"4.3",
|
|
88
|
+
"Ensure that unnecessary packages are not installed in the container",
|
|
89
|
+
)
|
|
90
|
+
CIS_4_4 = Control(
|
|
91
|
+
ControlSource.CIS_DOCKER,
|
|
92
|
+
"4.4",
|
|
93
|
+
"Ensure images are scanned and rebuilt to include security patches",
|
|
94
|
+
)
|
|
95
|
+
CIS_4_6 = Control(
|
|
96
|
+
ControlSource.CIS_DOCKER,
|
|
97
|
+
"4.6",
|
|
98
|
+
"Ensure that HEALTHCHECK instructions have been added to container images",
|
|
99
|
+
)
|
|
100
|
+
CIS_4_7 = Control(
|
|
101
|
+
ControlSource.CIS_DOCKER,
|
|
102
|
+
"4.7",
|
|
103
|
+
"Ensure update instructions are not used alone in the Dockerfile",
|
|
104
|
+
)
|
|
105
|
+
CIS_4_8 = Control(
|
|
106
|
+
ControlSource.CIS_DOCKER, "4.8", "Ensure setuid and setgid permissions are removed"
|
|
107
|
+
)
|
|
108
|
+
CIS_4_9 = Control(
|
|
109
|
+
ControlSource.CIS_DOCKER, "4.9", "Ensure that COPY is used instead of ADD in Dockerfiles"
|
|
110
|
+
)
|
|
111
|
+
CIS_4_10 = Control(ControlSource.CIS_DOCKER, "4.10", "Ensure secrets are not stored in Dockerfiles")
|
|
112
|
+
CIS_4_12 = Control(ControlSource.CIS_DOCKER, "4.12", "Ensure all signed artifacts are validated")
|
|
113
|
+
|
|
114
|
+
OWASP_2 = Control(ControlSource.OWASP_DOCKER, "RULE #2", "Set a user")
|
|
115
|
+
OWASP_4 = Control(
|
|
116
|
+
ControlSource.OWASP_DOCKER, "RULE #4", "Prevent in-container privilege escalation"
|
|
117
|
+
)
|
|
118
|
+
OWASP_9 = Control(
|
|
119
|
+
ControlSource.OWASP_DOCKER,
|
|
120
|
+
"RULE #9",
|
|
121
|
+
"Integrate container scanning tools into your CI/CD pipeline",
|
|
122
|
+
)
|
|
123
|
+
OWASP_12 = Control(
|
|
124
|
+
ControlSource.OWASP_DOCKER, "RULE #12", "Utilize Docker Secrets for Sensitive Data Management"
|
|
125
|
+
)
|
|
126
|
+
OWASP_13 = Control(ControlSource.OWASP_DOCKER, "RULE #13", "Enhance Supply Chain Security")
|
|
127
|
+
|
|
128
|
+
NIST_4_1_1 = Control(ControlSource.NIST_800_190, "4.1.1", "Image vulnerabilities")
|
|
129
|
+
NIST_4_1_2 = Control(ControlSource.NIST_800_190, "4.1.2", "Image configuration defects")
|
|
130
|
+
NIST_4_1_4 = Control(ControlSource.NIST_800_190, "4.1.4", "Embedded clear text secrets")
|
|
131
|
+
NIST_4_1_5 = Control(ControlSource.NIST_800_190, "4.1.5", "Use of untrusted images")
|
|
132
|
+
|
|
133
|
+
DOCKER_MULTISTAGE = Control(ControlSource.DOCKER_DOCS, "", "Multi-stage builds")
|
|
134
|
+
DOCKER_ENTRYPOINT = Control(ControlSource.DOCKER_DOCS, "", "Dockerfile reference: ENTRYPOINT")
|
|
135
|
+
DOCKER_BUILD_CONTEXT = Control(ControlSource.DOCKER_DOCS, "", "Build context and .dockerignore")
|
|
136
|
+
OCI_ANNOTATIONS = Control(ControlSource.OCI_SPEC, "", "Pre-defined annotation keys")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class RuleMapping:
|
|
141
|
+
"""A DockerLs rule, the controls it implements, and why it matters.
|
|
142
|
+
|
|
143
|
+
`rationale` is this project's own words: the controls say *what*, and a
|
|
144
|
+
reader deciding whether to act needs *why*, in terms of what an attacker
|
|
145
|
+
gains. Keeping them in separate fields is what stops a paraphrase from
|
|
146
|
+
being mistaken for a quotation.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
rule_id: str
|
|
150
|
+
summary: str
|
|
151
|
+
rationale: str
|
|
152
|
+
controls: tuple[Control, ...] = ()
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def is_documented(self) -> bool:
|
|
156
|
+
"""False when this is DockerLs's own guidance, not a published control."""
|
|
157
|
+
return bool(self.controls)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
RULE_MAPPINGS: tuple[RuleMapping, ...] = (
|
|
161
|
+
RuleMapping(
|
|
162
|
+
rule_id="DF001",
|
|
163
|
+
summary="Pin the base image",
|
|
164
|
+
rationale=(
|
|
165
|
+
"A floating tag means the image you tested and the image that ships can "
|
|
166
|
+
"differ with no change on your side, so nothing you verified about the "
|
|
167
|
+
"base still holds at deploy time."
|
|
168
|
+
),
|
|
169
|
+
controls=(CIS_4_2, NIST_4_1_5, OWASP_13),
|
|
170
|
+
),
|
|
171
|
+
RuleMapping(
|
|
172
|
+
rule_id="DF002",
|
|
173
|
+
summary="Run as a non-root user",
|
|
174
|
+
rationale=(
|
|
175
|
+
"A process running as uid 0 starts from the most privileged position "
|
|
176
|
+
"available inside the container, so any code-execution bug begins with "
|
|
177
|
+
"control of the filesystem and of anything mounted into it."
|
|
178
|
+
),
|
|
179
|
+
controls=(CIS_4_1, OWASP_2, NIST_4_1_2),
|
|
180
|
+
),
|
|
181
|
+
RuleMapping(
|
|
182
|
+
rule_id="DF003",
|
|
183
|
+
summary="Use a multi-stage build",
|
|
184
|
+
rationale=(
|
|
185
|
+
"Compilers, headers and package managers are needed to build and never "
|
|
186
|
+
"needed to run. A builder stage is how they stay out of the shipped "
|
|
187
|
+
"image without giving up a reproducible build."
|
|
188
|
+
),
|
|
189
|
+
controls=(CIS_4_3, DOCKER_MULTISTAGE),
|
|
190
|
+
),
|
|
191
|
+
RuleMapping(
|
|
192
|
+
rule_id="DF004",
|
|
193
|
+
summary="Keep secrets out of ENV and ARG",
|
|
194
|
+
rationale=(
|
|
195
|
+
"A value in ENV is in the image metadata, readable by anyone who can "
|
|
196
|
+
"pull it, and it survives in the layer history even if a later layer "
|
|
197
|
+
"removes it."
|
|
198
|
+
),
|
|
199
|
+
controls=(CIS_4_10, NIST_4_1_4, OWASP_12),
|
|
200
|
+
),
|
|
201
|
+
RuleMapping(
|
|
202
|
+
rule_id="DF005",
|
|
203
|
+
summary="Clean the package manager cache in the same layer",
|
|
204
|
+
rationale=(
|
|
205
|
+
"A cache removed in a later layer is still present in the one that "
|
|
206
|
+
"created it, so the image carries the bytes and the attack surface "
|
|
207
|
+
"while appearing not to."
|
|
208
|
+
),
|
|
209
|
+
controls=(CIS_4_3, CIS_4_7),
|
|
210
|
+
),
|
|
211
|
+
RuleMapping(
|
|
212
|
+
rule_id="DF006",
|
|
213
|
+
summary="Declare a HEALTHCHECK",
|
|
214
|
+
rationale=(
|
|
215
|
+
"An orchestrator that cannot distinguish a wedged container from a "
|
|
216
|
+
"healthy one keeps sending it traffic. This is availability rather "
|
|
217
|
+
"than confidentiality, which is why it weighs less than the rest."
|
|
218
|
+
),
|
|
219
|
+
controls=(CIS_4_6,),
|
|
220
|
+
),
|
|
221
|
+
RuleMapping(
|
|
222
|
+
rule_id="DF007",
|
|
223
|
+
summary="Label the image with provenance metadata",
|
|
224
|
+
rationale=(
|
|
225
|
+
"Standard annotations are what let a responder answer 'where did this "
|
|
226
|
+
"image come from and which commit built it' during an incident, "
|
|
227
|
+
"without guessing from the tag."
|
|
228
|
+
),
|
|
229
|
+
controls=(OCI_ANNOTATIONS,),
|
|
230
|
+
),
|
|
231
|
+
RuleMapping(
|
|
232
|
+
rule_id="DF008",
|
|
233
|
+
summary="Start from a minimal base",
|
|
234
|
+
rationale=(
|
|
235
|
+
"Every package in the base is code nobody in your organisation audited "
|
|
236
|
+
"and a future CVE somebody will have to triage; the smallest base that "
|
|
237
|
+
"runs your application is the smallest such backlog."
|
|
238
|
+
),
|
|
239
|
+
controls=(CIS_4_3, NIST_4_1_2),
|
|
240
|
+
),
|
|
241
|
+
RuleMapping(
|
|
242
|
+
rule_id="DF009",
|
|
243
|
+
summary="Do not install sudo",
|
|
244
|
+
rationale=(
|
|
245
|
+
"sudo exists to cross a privilege boundary, and it is setuid to do it. "
|
|
246
|
+
"In a container that should already be running unprivileged, it is a "
|
|
247
|
+
"ready-made escalation path."
|
|
248
|
+
),
|
|
249
|
+
controls=(CIS_4_8, OWASP_4),
|
|
250
|
+
),
|
|
251
|
+
RuleMapping(
|
|
252
|
+
rule_id="DF010",
|
|
253
|
+
summary="Use exec form for ENTRYPOINT and CMD",
|
|
254
|
+
rationale=(
|
|
255
|
+
"Shell form starts a shell as pid 1, which swallows SIGTERM: the "
|
|
256
|
+
"container stops being able to shut down cleanly, and orchestrators "
|
|
257
|
+
"fall back to killing it."
|
|
258
|
+
),
|
|
259
|
+
controls=(DOCKER_ENTRYPOINT,),
|
|
260
|
+
),
|
|
261
|
+
RuleMapping(
|
|
262
|
+
rule_id="DF011",
|
|
263
|
+
summary="Avoid shipping an interactive shell",
|
|
264
|
+
rationale=(
|
|
265
|
+
"A shell turns a limited primitive -- a file write, a template "
|
|
266
|
+
"injection -- into arbitrary command execution, and it is the first "
|
|
267
|
+
"thing most published container exploit chains reach for."
|
|
268
|
+
),
|
|
269
|
+
controls=(CIS_4_3, NIST_4_1_2),
|
|
270
|
+
),
|
|
271
|
+
RuleMapping(
|
|
272
|
+
rule_id="DF012",
|
|
273
|
+
summary="Keep a .dockerignore",
|
|
274
|
+
rationale=(
|
|
275
|
+
"Without one the build context carries .git, .env and local "
|
|
276
|
+
"credentials to the daemon, and anything a COPY happens to match ends "
|
|
277
|
+
"up in the image."
|
|
278
|
+
),
|
|
279
|
+
controls=(CIS_4_10, DOCKER_BUILD_CONTEXT),
|
|
280
|
+
),
|
|
281
|
+
RuleMapping(
|
|
282
|
+
rule_id="DF013",
|
|
283
|
+
summary="Use COPY instead of ADD",
|
|
284
|
+
rationale=(
|
|
285
|
+
"ADD does two things COPY does not, and neither is written on the line: "
|
|
286
|
+
"it fetches URLs with nothing verifying what came back, and it "
|
|
287
|
+
"auto-extracts local archives, so a tarball carrying '../../etc' writes "
|
|
288
|
+
"outside the destination. A reader sees what looks like a copy."
|
|
289
|
+
),
|
|
290
|
+
controls=(CIS_4_9, NIST_4_1_5),
|
|
291
|
+
),
|
|
292
|
+
RuleMapping(
|
|
293
|
+
rule_id="DF014",
|
|
294
|
+
summary="Do not pipe a downloaded script into a shell",
|
|
295
|
+
rationale=(
|
|
296
|
+
"`curl | sh` leaves the script nowhere: nothing signs it, nothing checks a "
|
|
297
|
+
"digest, and no layer records what ran. Whoever controls that host, the "
|
|
298
|
+
"path to it, or the DNS in between chooses what executes as root during "
|
|
299
|
+
"your build, and the Dockerfile keeps reading the same."
|
|
300
|
+
),
|
|
301
|
+
controls=(NIST_4_1_5, OWASP_13),
|
|
302
|
+
),
|
|
303
|
+
RuleMapping(
|
|
304
|
+
rule_id="DF015",
|
|
305
|
+
summary="Do not leave setuid or setgid binaries in the image",
|
|
306
|
+
rationale=(
|
|
307
|
+
"A setuid binary runs as its owner, and the owner is root. In a container "
|
|
308
|
+
"that correctly runs as an unprivileged user, it is the ready-made path "
|
|
309
|
+
"back to uid 0 -- the one piece missing to turn a limited command "
|
|
310
|
+
"execution into a complete one."
|
|
311
|
+
),
|
|
312
|
+
controls=(CIS_4_8, OWASP_4, NIST_4_1_2),
|
|
313
|
+
),
|
|
314
|
+
RuleMapping(
|
|
315
|
+
rule_id="DF016",
|
|
316
|
+
summary="Pin the version of every installed package",
|
|
317
|
+
rationale=(
|
|
318
|
+
"An unpinned `apt-get install`, `apk add`, `pip install` or `npm install` "
|
|
319
|
+
"resolves against whatever the index serves the day the build runs -- the "
|
|
320
|
+
"same Dockerfile can install different bytes, and a different CVE, "
|
|
321
|
+
"tomorrow than it did today. This is a reproducibility rule this project "
|
|
322
|
+
"checked no published benchmark for by name, not a citation stretched to "
|
|
323
|
+
"fit."
|
|
324
|
+
),
|
|
325
|
+
controls=(),
|
|
326
|
+
),
|
|
327
|
+
RuleMapping(
|
|
328
|
+
rule_id="DF017",
|
|
329
|
+
summary="Multi-architecture build awareness",
|
|
330
|
+
rationale=(
|
|
331
|
+
"`TARGETPLATFORM`/`--platform` have no correct state to check for, only "
|
|
332
|
+
"presence or absence -- this is informational, not a pass/fail control, "
|
|
333
|
+
"and exists to flag a package version pinned for one architecture that is "
|
|
334
|
+
"not guaranteed to exist, or to mean the same package, on another."
|
|
335
|
+
),
|
|
336
|
+
controls=(),
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
_BY_RULE: dict[str, RuleMapping] = {mapping.rule_id: mapping for mapping in RULE_MAPPINGS}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def mapping_for(rule_id: str | None) -> RuleMapping | None:
|
|
344
|
+
"""The mapping for `rule_id`, or None when the rule is not catalogued.
|
|
345
|
+
|
|
346
|
+
None is a legitimate answer, not a gap to paper over: a rule this
|
|
347
|
+
catalogue does not cover must render without a citation rather than
|
|
348
|
+
with a guessed one.
|
|
349
|
+
"""
|
|
350
|
+
if not rule_id:
|
|
351
|
+
return None
|
|
352
|
+
return _BY_RULE.get(rule_id.strip().upper())
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def controls_for(rule_id: str | None) -> tuple[Control, ...]:
|
|
356
|
+
mapping = mapping_for(rule_id)
|
|
357
|
+
return mapping.controls if mapping else ()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def references_for(rule_id: str | None) -> list[str]:
|
|
361
|
+
"""Citations as display strings, for serialisation and rendering."""
|
|
362
|
+
return [str(control) for control in controls_for(rule_id)]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Os objetos de valor do domínio, resolvidos sob demanda.
|
|
2
|
+
|
|
3
|
+
Este pacote reexportava tudo no topo do arquivo, e o preço não era o
|
|
4
|
+
import em si: cada símbolo aqui é um modelo pydantic, e o pydantic compila
|
|
5
|
+
o modelo *no momento do import*. Como `import
|
|
6
|
+
dockerls.domain.value_objects.image_reference` -- uma função de parsing de
|
|
7
|
+
strings, sem dependência nenhuma -- executa o `__init__` do pacote antes de
|
|
8
|
+
chegar no submódulo, todo comando da CLI pagava essa compilação. Eram 82ms
|
|
9
|
+
em `dockerls --help`, que não pontua imagem nenhuma.
|
|
10
|
+
|
|
11
|
+
O `__getattr__` de módulo (PEP 562) mantém `from dockerls.domain.value_objects
|
|
12
|
+
import SecurityScore` funcionando exatamente como antes; o submódulo só é
|
|
13
|
+
importado quando alguém realmente pede o nome.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from dockerls.domain.value_objects.remediation_score import RemediationScore
|
|
22
|
+
from dockerls.domain.value_objects.security_score import SecurityScore
|
|
23
|
+
from dockerls.domain.value_objects.security_tier import SecurityTier
|
|
24
|
+
|
|
25
|
+
#: Nome público -> submódulo que o define.
|
|
26
|
+
_EXPORTS = {
|
|
27
|
+
"RemediationScore": "remediation_score",
|
|
28
|
+
"SecurityScore": "security_score",
|
|
29
|
+
"SecurityTier": "security_tier",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
__all__ = ["RemediationScore", "SecurityScore", "SecurityTier"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def __getattr__(name: str) -> Any:
|
|
36
|
+
module = _EXPORTS.get(name)
|
|
37
|
+
if module is None:
|
|
38
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
39
|
+
from importlib import import_module
|
|
40
|
+
|
|
41
|
+
value = getattr(import_module(f"{__name__}.{module}"), name)
|
|
42
|
+
# Cacheado no próprio módulo: `__getattr__` só é consultado quando o
|
|
43
|
+
# nome não está no namespace, então o segundo acesso não paga nada.
|
|
44
|
+
globals()[name] = value
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def __dir__() -> list[str]:
|
|
49
|
+
return sorted(__all__)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Attack surface: how much an attacker inherits after reaching the container.
|
|
2
|
+
|
|
3
|
+
Distinct from hardening, and distinct again from vulnerabilities. Hardening
|
|
4
|
+
asks "is this configured defensively"; attack surface asks "if code
|
|
5
|
+
execution happens inside this container, what is already there to use". A
|
|
6
|
+
shell, a package manager, a compiler and a network fetcher are each a rung
|
|
7
|
+
on that ladder, and none of them is a CVE.
|
|
8
|
+
|
|
9
|
+
**Size is not surface.** A 900 MB image built from a single statically
|
|
10
|
+
linked binary has a smaller attack surface than a 40 MB image carrying
|
|
11
|
+
busybox, apk and curl. Bytes are therefore not scored here at all; package
|
|
12
|
+
*count* is, because a package is a piece of installed functionality, and
|
|
13
|
+
capabilities are, because they are what the functionality can do.
|
|
14
|
+
|
|
15
|
+
The scale runs 0 (nothing found that expands surface) to 100 (every scored
|
|
16
|
+
item present). Like hardening, it is computed over determined facts only and
|
|
17
|
+
reports its coverage, so an image nobody could inspect reads as "unknown",
|
|
18
|
+
never as "clean".
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from dockerls.domain.entities.image_facts import HardeningFacts
|
|
27
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
28
|
+
|
|
29
|
+
#: Weights, highest first. A package manager outweighs a shell: a shell lets
|
|
30
|
+
#: an attacker use what is already installed, while a package manager lets
|
|
31
|
+
#: them install whatever is missing.
|
|
32
|
+
PACKAGE_MANAGER_WEIGHT = 25.0
|
|
33
|
+
SHELL_WEIGHT = 20.0
|
|
34
|
+
DEBUG_TOOLS_WEIGHT = 15.0
|
|
35
|
+
SETUID_WEIGHT = 15.0
|
|
36
|
+
ROOT_DEFAULT_WEIGHT = 15.0
|
|
37
|
+
PACKAGE_VOLUME_WEIGHT = 10.0
|
|
38
|
+
|
|
39
|
+
TOTAL_WEIGHT = (
|
|
40
|
+
PACKAGE_MANAGER_WEIGHT
|
|
41
|
+
+ SHELL_WEIGHT
|
|
42
|
+
+ DEBUG_TOOLS_WEIGHT
|
|
43
|
+
+ SETUID_WEIGHT
|
|
44
|
+
+ ROOT_DEFAULT_WEIGHT
|
|
45
|
+
+ PACKAGE_VOLUME_WEIGHT
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
#: Package count at which the volume term saturates. A runtime image with a
|
|
49
|
+
#: few hundred packages carries a large amount of code nobody audited.
|
|
50
|
+
PACKAGE_VOLUME_CEILING = 400
|
|
51
|
+
|
|
52
|
+
#: Below this share of determined weight the number is not reported, for the
|
|
53
|
+
#: same reason as in the hardening model: it would look like a measurement.
|
|
54
|
+
MIN_REPORTABLE_COVERAGE = 0.25
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SurfaceItem(NamedTuple):
|
|
58
|
+
"""One scored element of the surface, kept so the score can explain itself."""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
weight: float
|
|
62
|
+
#: Surface contributed, 0.0 to `weight`.
|
|
63
|
+
contributed: float
|
|
64
|
+
determined: bool
|
|
65
|
+
detail: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AttackSurfaceScore:
|
|
69
|
+
"""0-100 where **higher means more surface**, over determined facts only.
|
|
70
|
+
|
|
71
|
+
The inverted direction relative to every other score in this codebase is
|
|
72
|
+
deliberate and is stated everywhere the number is rendered: "attack
|
|
73
|
+
surface 80" must never be mistaken for "80% good".
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, facts: HardeningFacts):
|
|
77
|
+
self._items = _surface_items(facts)
|
|
78
|
+
determined = [item for item in self._items if item.determined]
|
|
79
|
+
self._available = sum(item.weight for item in determined)
|
|
80
|
+
self._contributed = sum(item.contributed for item in determined)
|
|
81
|
+
self._value = (
|
|
82
|
+
round(100.0 * self._contributed / self._available, 1) if self._available > 0 else 0.0
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def value(self) -> float:
|
|
87
|
+
return self._value
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def coverage(self) -> float:
|
|
91
|
+
return round(self._available / TOTAL_WEIGHT, 3)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def is_reportable(self) -> bool:
|
|
95
|
+
return self.coverage >= MIN_REPORTABLE_COVERAGE
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def items(self) -> list[SurfaceItem]:
|
|
99
|
+
return list(self._items)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def present(self) -> list[str]:
|
|
103
|
+
"""Surface elements confirmed present, for the explanation text."""
|
|
104
|
+
return [
|
|
105
|
+
item.detail or item.name
|
|
106
|
+
for item in self._items
|
|
107
|
+
if item.determined and item.contributed > 0.0
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def absent(self) -> list[str]:
|
|
112
|
+
"""Surface elements confirmed absent -- a genuine finding, not a default."""
|
|
113
|
+
return [
|
|
114
|
+
item.detail or item.name
|
|
115
|
+
for item in self._items
|
|
116
|
+
if item.determined and item.contributed <= 0.0
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _surface_items(facts: HardeningFacts) -> list[SurfaceItem]:
|
|
121
|
+
return [
|
|
122
|
+
_from_presence(
|
|
123
|
+
"package-manager",
|
|
124
|
+
PACKAGE_MANAGER_WEIGHT,
|
|
125
|
+
facts.has_package_manager,
|
|
126
|
+
present="package manager installed",
|
|
127
|
+
absent="no package manager",
|
|
128
|
+
),
|
|
129
|
+
_from_presence(
|
|
130
|
+
"shell", SHELL_WEIGHT, facts.has_shell, present="shell present", absent="no shell"
|
|
131
|
+
),
|
|
132
|
+
_from_presence(
|
|
133
|
+
"debug-tools",
|
|
134
|
+
DEBUG_TOOLS_WEIGHT,
|
|
135
|
+
facts.has_debug_tools,
|
|
136
|
+
present="compilers or network utilities present",
|
|
137
|
+
absent="no compilers or network utilities",
|
|
138
|
+
),
|
|
139
|
+
_from_presence(
|
|
140
|
+
"setuid",
|
|
141
|
+
SETUID_WEIGHT,
|
|
142
|
+
facts.has_setuid,
|
|
143
|
+
present="SUID/SGID binaries present",
|
|
144
|
+
absent="no SUID/SGID binaries",
|
|
145
|
+
),
|
|
146
|
+
_root_default(facts),
|
|
147
|
+
_package_volume(facts),
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _from_presence(
|
|
152
|
+
name: str, weight: float, state: Tristate, *, present: str, absent: str
|
|
153
|
+
) -> SurfaceItem:
|
|
154
|
+
if not state.is_known:
|
|
155
|
+
return SurfaceItem(name, weight, 0.0, determined=False)
|
|
156
|
+
contributed = weight if state.is_true else 0.0
|
|
157
|
+
return SurfaceItem(
|
|
158
|
+
name, weight, contributed, determined=True, detail=present if state.is_true else absent
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _root_default(facts: HardeningFacts) -> SurfaceItem:
|
|
163
|
+
"""Running as root multiplies what every other element is worth."""
|
|
164
|
+
name = "root-default"
|
|
165
|
+
if not facts.runs_as_non_root.is_known:
|
|
166
|
+
return SurfaceItem(name, ROOT_DEFAULT_WEIGHT, 0.0, determined=False)
|
|
167
|
+
if facts.runs_as_non_root.is_true:
|
|
168
|
+
return SurfaceItem(
|
|
169
|
+
name, ROOT_DEFAULT_WEIGHT, 0.0, determined=True, detail="runs unprivileged"
|
|
170
|
+
)
|
|
171
|
+
return SurfaceItem(
|
|
172
|
+
name,
|
|
173
|
+
ROOT_DEFAULT_WEIGHT,
|
|
174
|
+
ROOT_DEFAULT_WEIGHT,
|
|
175
|
+
determined=True,
|
|
176
|
+
detail="runs as root by default",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _package_volume(facts: HardeningFacts) -> SurfaceItem:
|
|
181
|
+
"""Installed code volume, scaled linearly to a ceiling.
|
|
182
|
+
|
|
183
|
+
Counted in packages, not bytes: the question is how much distinct
|
|
184
|
+
installed functionality exists, and a single large binary is one thing
|
|
185
|
+
to audit while three hundred small packages are three hundred.
|
|
186
|
+
"""
|
|
187
|
+
name = "package-volume"
|
|
188
|
+
count = facts.package_count
|
|
189
|
+
if count is None:
|
|
190
|
+
return SurfaceItem(name, PACKAGE_VOLUME_WEIGHT, 0.0, determined=False)
|
|
191
|
+
ratio = min(1.0, max(0, count) / PACKAGE_VOLUME_CEILING)
|
|
192
|
+
return SurfaceItem(
|
|
193
|
+
name,
|
|
194
|
+
PACKAGE_VOLUME_WEIGHT,
|
|
195
|
+
round(PACKAGE_VOLUME_WEIGHT * ratio, 3),
|
|
196
|
+
determined=True,
|
|
197
|
+
detail=f"{count} installed packages",
|
|
198
|
+
)
|