dependabot-docker 0.363.0 → 0.364.0
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.
- checksums.yaml +4 -4
- data/lib/dependabot/docker/tag.rb +30 -1
- data/lib/dependabot/docker/update_checker.rb +527 -46
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 62746febf2f7781d370bf9ae465d76c03ae5b8afb926d10eb0d27091ab0ac08c
|
|
4
|
+
data.tar.gz: a600410ab9c7b7d121c639898654f8a134033da58b6f98118c3da7bca9d75a4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c26d57456c177d176c94a32e9dea887d302de313be6d5e530196c29da08de26664f2e9212e2179d99c5f5defeb41d40ca3bfd61ad9133662f52b6dd8aa3d45ba
|
|
7
|
+
data.tar.gz: 8f22ab97605010d3a7ea626cedcd838b1245c2e73a4412ff0829daa9c1c1a256275a49eb07020fc1b45e498ac62d3fbfd04e13d5ae4fb1f2df321080ee54dfde
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "dependabot/docker/file_parser"
|
|
5
|
+
require "dependabot/experiments"
|
|
5
6
|
require "sorbet-runtime"
|
|
6
7
|
|
|
7
8
|
module Dependabot
|
|
@@ -99,6 +100,12 @@ module Dependabot
|
|
|
99
100
|
comparable_format = comparable_formats(other.format, other.prefix, other.suffix)
|
|
100
101
|
return equal_prefix && equal_format if other_format == :sha_suffixed
|
|
101
102
|
|
|
103
|
+
# When timestamp validation is enabled, dated and non-dated versions are
|
|
104
|
+
# not comparable — prevents updating from 4.8-windowsservercore-ltsc2022
|
|
105
|
+
# to 4.8-20250909-windowsservercore-ltsc2022 and vice versa
|
|
106
|
+
return false if Dependabot::Experiments.enabled?(:docker_created_timestamp_validation) &&
|
|
107
|
+
dated_version? != other.dated_version?
|
|
108
|
+
|
|
102
109
|
equal_suffix = suffix == other_suffix
|
|
103
110
|
(equal_prefix && equal_format && equal_suffix) || comparable_format
|
|
104
111
|
end
|
|
@@ -171,7 +178,29 @@ module Dependabot
|
|
|
171
178
|
def numeric_version
|
|
172
179
|
return unless comparable?
|
|
173
180
|
|
|
174
|
-
version&.gsub(/kb/i, "")&.gsub(/-[a-z]+/, "")&.downcase
|
|
181
|
+
result = version&.gsub(/kb/i, "")&.gsub(/-[a-z]+/, "")&.downcase
|
|
182
|
+
# When timestamp validation is enabled, strip date components from the
|
|
183
|
+
# version so they don't inflate semver comparison. "4.8-20250909" should
|
|
184
|
+
# compare as "4.8", not "4.8.20250909". The date is metadata — actual
|
|
185
|
+
# recency is determined by config blob timestamps.
|
|
186
|
+
if Dependabot::Experiments.enabled?(:docker_created_timestamp_validation)
|
|
187
|
+
result = result&.gsub(/-\d{8}(?:\b|$)/, "")
|
|
188
|
+
end
|
|
189
|
+
result
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Detects whether the version part contains a date-like component (YYYYMMDD).
|
|
193
|
+
# For example, "4.8-20250909" has a dated version, "4.8.1" does not.
|
|
194
|
+
# This is used to prevent cross-updates between dated and non-dated tags.
|
|
195
|
+
# NOTE: This method only checks for the presence of an 8-digit date-like segment in the version part.
|
|
196
|
+
# It does not attempt to validate the date itself. Nor does it check for other date formats
|
|
197
|
+
# (e.g., YYYY.MM.DD, YYYY-MM-DD, MM.DD.YYYY)
|
|
198
|
+
sig { returns(T::Boolean) }
|
|
199
|
+
def dated_version?
|
|
200
|
+
return false unless version
|
|
201
|
+
|
|
202
|
+
# Match 8-digit date-like segments (YYYYMMDD) within the version part
|
|
203
|
+
!!T.must(version).match?(/-\d{8}(?:\b|$)/)
|
|
175
204
|
end
|
|
176
205
|
|
|
177
206
|
sig { returns(Integer) }
|
|
@@ -22,6 +22,67 @@ module Dependabot
|
|
|
22
22
|
class UpdateChecker < Dependabot::UpdateCheckers::Base
|
|
23
23
|
extend T::Sig
|
|
24
24
|
|
|
25
|
+
MANIFEST_LIST_TYPES = T.let(
|
|
26
|
+
[
|
|
27
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
28
|
+
"application/vnd.oci.image.index.v1+json"
|
|
29
|
+
].freeze,
|
|
30
|
+
T::Array[String]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Tolerance window for platform timestamp comparison.
|
|
34
|
+
# Multi-arch CI builds may finish platforms at slightly different times.
|
|
35
|
+
PLATFORM_TIMESTAMP_TOLERANCE_SECONDS = T.let(3 * 60 * 60, Integer)
|
|
36
|
+
|
|
37
|
+
# Maximum number of candidates to run platform timestamp validation against.
|
|
38
|
+
# Each validation can require 1 + 1 + N*2 registry API calls for N platforms,
|
|
39
|
+
# so we cap the attempts to avoid rate limiting or excessive latency.
|
|
40
|
+
MAX_PLATFORM_VALIDATION_ATTEMPTS = T.let(5, Integer)
|
|
41
|
+
|
|
42
|
+
# Legacy patterns used when docker_created_timestamp_validation experiment is disabled.
|
|
43
|
+
# The broad alphanumeric regex matches tokens like "alpine3", "ltsc2022", "rc1"
|
|
44
|
+
# and classifies them as version-related, preserving pre-experiment behavior.
|
|
45
|
+
LEGACY_VERSION_RELATED_PATTERNS = T.let(
|
|
46
|
+
[
|
|
47
|
+
/^\d+$/, # pure numbers: "123", "8"
|
|
48
|
+
/^\d+\.\d+$/, # semver-like: "1.2"
|
|
49
|
+
/^v\d+/, # v-prefixed: "v2", "v10"
|
|
50
|
+
/^(?=.*\d)(?=.*[a-z])[a-z\d]+$/i, # broad mixed alphanumeric: "rc1", "beta2", "alpine3", "ltsc2022"
|
|
51
|
+
/^(rc|jre)$/, # common Docker tag components that are part of versioning
|
|
52
|
+
/^kb\d+$/i, # Microsoft KB numbers: "KB4505057"
|
|
53
|
+
/^g[0-9a-f]{5,}$/, # git SHAs: "g1a2b3c4"
|
|
54
|
+
/^\d{8,14}$/, # timestamps: "20250909"
|
|
55
|
+
/\d+_\d+/ # underscore-separated version parts: "12_8"
|
|
56
|
+
].freeze,
|
|
57
|
+
T::Array[Regexp]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Patterns that identify structurally obvious version components in tag
|
|
61
|
+
# names. Matching parts are excluded from the common-component system
|
|
62
|
+
# because they represent version data, not platform/variant identifiers.
|
|
63
|
+
#
|
|
64
|
+
# Everything that does NOT match these patterns is treated as a
|
|
65
|
+
# platform/variant component (e.g., "alpine3", "ltsc2022", "bookworm",
|
|
66
|
+
# "rc1", "jre"). This is intentionally broad — the primary tag filtering
|
|
67
|
+
# in comparable_to? already handles prerelease and suffix isolation via
|
|
68
|
+
# exact suffix matching, so component matching is a secondary safety net.
|
|
69
|
+
#
|
|
70
|
+
# To exclude a new structural pattern, add a regex here.
|
|
71
|
+
# Only used when docker_created_timestamp_validation experiment is enabled.
|
|
72
|
+
VERSION_RELATED_PATTERNS = T.let(
|
|
73
|
+
[
|
|
74
|
+
/^\d+$/, # pure numbers: "123", "8"
|
|
75
|
+
/^\d+\.\d+$/, # semver-like: "1.2"
|
|
76
|
+
/^v\d+/, # v-prefixed: "v2", "v10"
|
|
77
|
+
/^\d+[a-z]+\d+$/i, # digit-letters-digit version parts: "0a1", "0b1", "0rc1"
|
|
78
|
+
/^kb\d+$/i, # Microsoft KB numbers: "KB4505057"
|
|
79
|
+
/^g[0-9a-f]{5,}$/, # git SHAs: "g1a2b3c4"
|
|
80
|
+
/^\d{8,14}$/, # timestamps: "20250909"
|
|
81
|
+
/\d+_\d+/ # underscore-separated version parts: "12_8"
|
|
82
|
+
].freeze,
|
|
83
|
+
T::Array[Regexp]
|
|
84
|
+
)
|
|
85
|
+
|
|
25
86
|
sig { override.returns(T.nilable(T.any(String, Gem::Version))) }
|
|
26
87
|
def latest_version
|
|
27
88
|
latest_version_from(T.must(dependency.version))
|
|
@@ -99,6 +160,16 @@ module Dependabot
|
|
|
99
160
|
|
|
100
161
|
latest_tag = latest_tag_from(version)
|
|
101
162
|
|
|
163
|
+
# When timestamp validation is enabled, comparable_version_from strips
|
|
164
|
+
# date components (e.g. 4.8.1-20251014 -> 4.8.1), so two dated tags
|
|
165
|
+
# with different dates but the same base version compare as equal.
|
|
166
|
+
# Detect this case by checking the tag names directly.
|
|
167
|
+
if Dependabot::Experiments.enabled?(:docker_created_timestamp_validation) &&
|
|
168
|
+
version_tag.dated_version? && latest_tag.dated_version? &&
|
|
169
|
+
latest_tag.name != version_tag.name
|
|
170
|
+
return false
|
|
171
|
+
end
|
|
172
|
+
|
|
102
173
|
comparable_version_from(latest_tag) <= comparable_version_from(version_tag)
|
|
103
174
|
end
|
|
104
175
|
|
|
@@ -154,42 +225,108 @@ module Dependabot
|
|
|
154
225
|
candidate_tags = sort_tags(candidate_tags, version_tag)
|
|
155
226
|
candidate_tags = apply_cooldown(candidate_tags)
|
|
156
227
|
|
|
157
|
-
|
|
158
|
-
|
|
228
|
+
select_best_candidate(candidate_tags, version_tag)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
sig do
|
|
232
|
+
params(
|
|
233
|
+
candidate_tags: T::Array[Dependabot::Docker::Tag],
|
|
234
|
+
version_tag: Dependabot::Docker::Tag
|
|
235
|
+
).returns(Dependabot::Docker::Tag)
|
|
236
|
+
end
|
|
237
|
+
def select_best_candidate(candidate_tags, version_tag)
|
|
238
|
+
same_precision_tags = remove_precision_changes(candidate_tags, version_tag)
|
|
239
|
+
validation_attempts = 0
|
|
240
|
+
|
|
241
|
+
# Iterate from highest to lowest, trying each candidate until one passes validation
|
|
242
|
+
candidate_tags.reverse_each do |candidate|
|
|
243
|
+
selected = select_tag_with_precision(candidate, same_precision_tags, version_tag)
|
|
244
|
+
|
|
245
|
+
if Dependabot::Experiments.enabled?(:docker_created_timestamp_validation) &&
|
|
246
|
+
selected.name != version_tag.name
|
|
247
|
+
if validation_attempts >= MAX_PLATFORM_VALIDATION_ATTEMPTS
|
|
248
|
+
Dependabot.logger.info(
|
|
249
|
+
"Platform validation: reached max attempts (#{MAX_PLATFORM_VALIDATION_ATTEMPTS}), " \
|
|
250
|
+
"accepting #{selected.name} without timestamp check"
|
|
251
|
+
)
|
|
252
|
+
return selected
|
|
253
|
+
end
|
|
254
|
+
validation_attempts += 1
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
validated = validate_tag_with_timestamp(selected, version_tag)
|
|
258
|
+
return validated unless validated.name == version_tag.name && selected.name != version_tag.name
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
if validation_attempts.positive?
|
|
262
|
+
Dependabot.logger.info(
|
|
263
|
+
"Platform validation: exhausted all #{validation_attempts} candidate(s) " \
|
|
264
|
+
"for #{version_tag.name}, staying on current version"
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
version_tag
|
|
269
|
+
end
|
|
159
270
|
|
|
160
|
-
|
|
271
|
+
sig do
|
|
272
|
+
params(
|
|
273
|
+
candidate: Dependabot::Docker::Tag,
|
|
274
|
+
same_precision_tags: T::Array[Dependabot::Docker::Tag],
|
|
275
|
+
version_tag: Dependabot::Docker::Tag
|
|
276
|
+
).returns(Dependabot::Docker::Tag)
|
|
277
|
+
end
|
|
278
|
+
def select_tag_with_precision(candidate, same_precision_tags, version_tag)
|
|
279
|
+
return candidate if candidate.same_precision?(version_tag)
|
|
161
280
|
|
|
162
|
-
|
|
163
|
-
|
|
281
|
+
# Find the highest same-precision tag that is <= this candidate
|
|
282
|
+
best_same_precision = same_precision_tags.reverse.find do |t|
|
|
283
|
+
comparable_version_from(t) <= comparable_version_from(candidate)
|
|
284
|
+
end
|
|
164
285
|
|
|
165
|
-
|
|
166
|
-
latest_digest = digest_of(latest_tag.name)
|
|
286
|
+
return candidate unless best_same_precision
|
|
167
287
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
#
|
|
171
|
-
# In that case we can't know for sure whether the latest tag keeping
|
|
172
|
-
# existing precision is the same as the absolute latest tag.
|
|
173
|
-
#
|
|
174
|
-
# We can however, make a best-effort to avoid unwanted changes by
|
|
175
|
-
# directly looking at version numbers and checking whether the absolute
|
|
176
|
-
# latest tag is just a more precise version of the latest tag that keeps
|
|
177
|
-
# existing precision.
|
|
288
|
+
same_precision_digest = digest_of(best_same_precision.name)
|
|
289
|
+
candidate_digest = digest_of(candidate.name)
|
|
178
290
|
|
|
179
|
-
if
|
|
180
|
-
|
|
291
|
+
if same_precision_digest == candidate_digest &&
|
|
292
|
+
best_same_precision.same_but_less_precise?(candidate)
|
|
293
|
+
best_same_precision
|
|
181
294
|
else
|
|
182
|
-
|
|
295
|
+
candidate
|
|
183
296
|
end
|
|
184
297
|
end
|
|
185
298
|
|
|
299
|
+
sig do
|
|
300
|
+
params(
|
|
301
|
+
selected_tag: Dependabot::Docker::Tag,
|
|
302
|
+
current_tag: Dependabot::Docker::Tag
|
|
303
|
+
).returns(Dependabot::Docker::Tag)
|
|
304
|
+
end
|
|
305
|
+
def validate_tag_with_timestamp(selected_tag, current_tag)
|
|
306
|
+
return selected_tag unless Dependabot::Experiments.enabled?(:docker_created_timestamp_validation)
|
|
307
|
+
return selected_tag if selected_tag.name == current_tag.name
|
|
308
|
+
|
|
309
|
+
if validate_candidate_platforms(selected_tag, current_tag)
|
|
310
|
+
Dependabot.logger.info(
|
|
311
|
+
"Platform validation: #{selected_tag.name} confirmed valid update from #{current_tag.name}"
|
|
312
|
+
)
|
|
313
|
+
return selected_tag
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
Dependabot.logger.info(
|
|
317
|
+
"Platform validation: skipping #{selected_tag.name} — " \
|
|
318
|
+
"platform check failed against #{current_tag.name}"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
current_tag
|
|
322
|
+
end
|
|
323
|
+
|
|
186
324
|
sig { params(original_tag: Dependabot::Docker::Tag).returns(T::Array[Dependabot::Docker::Tag]) }
|
|
187
325
|
def comparable_tags_from_registry(original_tag)
|
|
188
326
|
common_components = identify_common_components(tags_from_registry)
|
|
189
327
|
original_components = extract_tag_components(original_tag.name, common_components)
|
|
190
328
|
Dependabot.logger.info("Original tag components: #{original_components.join(',')}")
|
|
191
329
|
|
|
192
|
-
tags_from_registry.select { |tag| tag.comparable_to?(original_tag) }
|
|
193
330
|
tags_from_registry.select do |tag|
|
|
194
331
|
tag.comparable_to?(original_tag) &&
|
|
195
332
|
(original_components.empty? ||
|
|
@@ -333,18 +470,12 @@ module Dependabot
|
|
|
333
470
|
|
|
334
471
|
sig { params(part: String).returns(T::Boolean) }
|
|
335
472
|
def version_related_pattern?(part)
|
|
336
|
-
patterns =
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
sha: /^g[0-9a-f]{5,}$/,
|
|
343
|
-
timestamp: /^\d{8,14}$/,
|
|
344
|
-
underscore_parts: /\d+_\d+/
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
patterns.values.any? { |pattern| part.match?(pattern) }
|
|
473
|
+
patterns = if Dependabot::Experiments.enabled?(:docker_created_timestamp_validation)
|
|
474
|
+
VERSION_RELATED_PATTERNS
|
|
475
|
+
else
|
|
476
|
+
LEGACY_VERSION_RELATED_PATTERNS
|
|
477
|
+
end
|
|
478
|
+
patterns.any? { |pattern| part.match?(pattern) }
|
|
348
479
|
end
|
|
349
480
|
|
|
350
481
|
sig { params(tag_name: String, common_components: T::Array[String]).returns(T::Array[String]) }
|
|
@@ -601,19 +732,42 @@ module Dependabot
|
|
|
601
732
|
.returns(T::Array[Dependabot::Docker::Tag])
|
|
602
733
|
end
|
|
603
734
|
def sort_tags(candidate_tags, version_tag)
|
|
604
|
-
candidate_tags.sort
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
735
|
+
candidate_tags.sort { |tag_a, tag_b| compare_tags(tag_a, tag_b, version_tag) }
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
sig do
|
|
739
|
+
params(
|
|
740
|
+
tag_a: Dependabot::Docker::Tag,
|
|
741
|
+
tag_b: Dependabot::Docker::Tag,
|
|
742
|
+
version_tag: Dependabot::Docker::Tag
|
|
743
|
+
).returns(Integer)
|
|
744
|
+
end
|
|
745
|
+
def compare_tags(tag_a, tag_b, version_tag)
|
|
746
|
+
version_cmp = comparable_version_from(tag_a) <=> comparable_version_from(tag_b)
|
|
747
|
+
return version_cmp if version_cmp && version_cmp != 0
|
|
748
|
+
|
|
749
|
+
precision_cmp = compare_precision(tag_a, tag_b, version_tag)
|
|
750
|
+
return precision_cmp unless precision_cmp.zero?
|
|
751
|
+
|
|
752
|
+
# When versions and precision are equal (e.g., dated tags with same base version),
|
|
753
|
+
# use the raw version string as tiebreaker so newer dates sort higher
|
|
754
|
+
((tag_a.version || "") <=> (tag_b.version || "")) || 0
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
sig do
|
|
758
|
+
params(
|
|
759
|
+
tag_a: Dependabot::Docker::Tag,
|
|
760
|
+
tag_b: Dependabot::Docker::Tag,
|
|
761
|
+
version_tag: Dependabot::Docker::Tag
|
|
762
|
+
).returns(Integer)
|
|
763
|
+
end
|
|
764
|
+
def compare_precision(tag_a, tag_b, version_tag)
|
|
765
|
+
a_match = tag_a.same_precision?(version_tag)
|
|
766
|
+
b_match = tag_b.same_precision?(version_tag)
|
|
767
|
+
return 1 if a_match && !b_match
|
|
768
|
+
return -1 if b_match && !a_match
|
|
769
|
+
|
|
770
|
+
0
|
|
617
771
|
end
|
|
618
772
|
|
|
619
773
|
sig { params(candidate_tags: T::Array[Dependabot::Docker::Tag]).returns(T::Array[Dependabot::Docker::Tag]) }
|
|
@@ -685,6 +839,333 @@ module Dependabot
|
|
|
685
839
|
days = cooldown_days_for
|
|
686
840
|
(Time.now.to_i - release_date.to_i) < (days * 24 * 60 * 60)
|
|
687
841
|
end
|
|
842
|
+
|
|
843
|
+
# Fetches the "created" timestamp from the image config blob for a given tag.
|
|
844
|
+
# This represents the actual build time, which is more reliable than semver
|
|
845
|
+
# for determining which image is truly newer.
|
|
846
|
+
sig { params(tag_name: String).returns(T.nilable(Time)) }
|
|
847
|
+
def fetch_image_config_created(tag_name)
|
|
848
|
+
return config_created_timestamps[tag_name] if config_created_timestamps.key?(tag_name)
|
|
849
|
+
|
|
850
|
+
created = fetch_image_config_created_from_registry(tag_name)
|
|
851
|
+
config_created_timestamps[tag_name] = created
|
|
852
|
+
created
|
|
853
|
+
rescue *transient_docker_errors, DockerRegistry2::RegistryAuthenticationException,
|
|
854
|
+
RestClient::Forbidden, JSON::ParserError => e
|
|
855
|
+
Dependabot.logger.info(
|
|
856
|
+
"Failed to fetch config created timestamp for #{docker_repo_name}:#{tag_name}: #{e.message}"
|
|
857
|
+
)
|
|
858
|
+
config_created_timestamps[tag_name] = nil
|
|
859
|
+
nil
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
sig { params(tag_name: String).returns(T.nilable(Time)) }
|
|
863
|
+
def fetch_image_config_created_from_registry(tag_name)
|
|
864
|
+
manifest = with_retries(max_attempts: 3, errors: transient_docker_errors) do
|
|
865
|
+
docker_registry_client.manifest(docker_repo_name, tag_name)
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
resolved = resolve_platform_manifest(manifest)
|
|
869
|
+
return nil unless resolved
|
|
870
|
+
|
|
871
|
+
config_digest = resolved.dig("config", "digest")
|
|
872
|
+
return nil unless config_digest
|
|
873
|
+
|
|
874
|
+
parse_created_from_config_blob(config_digest)
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# Fetches and parses the "created" timestamp from a config blob identified by its digest.
|
|
878
|
+
sig { params(config_digest: String).returns(T.nilable(Time)) }
|
|
879
|
+
def parse_created_from_config_blob(config_digest)
|
|
880
|
+
config_blob = with_retries(max_attempts: 3, errors: transient_docker_errors) do
|
|
881
|
+
docker_registry_client.doget("v2/#{docker_repo_name}/blobs/#{config_digest}")
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
config_data = JSON.parse(config_blob.body)
|
|
885
|
+
created_str = config_data["created"]
|
|
886
|
+
return nil unless created_str
|
|
887
|
+
|
|
888
|
+
Time.parse(created_str)
|
|
889
|
+
rescue ArgumentError => e
|
|
890
|
+
Dependabot.logger.info(
|
|
891
|
+
"Failed to parse config created timestamp for #{docker_repo_name} blob #{config_digest}: #{e.message}"
|
|
892
|
+
)
|
|
893
|
+
nil
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
# Resolves a manifest to a single platform-specific manifest.
|
|
897
|
+
# If the manifest is a manifest list (multi-arch), selects the most
|
|
898
|
+
# appropriate platform (preferring linux/amd64).
|
|
899
|
+
sig { params(manifest: T.untyped).returns(T.nilable(T::Hash[String, T.untyped])) }
|
|
900
|
+
def resolve_platform_manifest(manifest)
|
|
901
|
+
media_type = manifest["mediaType"] || manifest[:mediaType]
|
|
902
|
+
|
|
903
|
+
unless MANIFEST_LIST_TYPES.include?(media_type)
|
|
904
|
+
return manifest.is_a?(Hash) ? manifest : manifest.to_h
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
platform_digest = select_platform_digest(manifest)
|
|
908
|
+
return nil unless platform_digest
|
|
909
|
+
|
|
910
|
+
platform_manifest = with_retries(max_attempts: 3, errors: transient_docker_errors) do
|
|
911
|
+
docker_registry_client.doget("v2/#{docker_repo_name}/manifests/#{platform_digest}")
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
JSON.parse(platform_manifest.body)
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
# Selects the digest of the best platform-specific manifest from a manifest list,
|
|
918
|
+
# preferring linux/amd64.
|
|
919
|
+
sig { params(manifest: T.untyped).returns(T.nilable(String)) }
|
|
920
|
+
def select_platform_digest(manifest)
|
|
921
|
+
manifests = manifest["manifests"] || manifest[:manifests] || []
|
|
922
|
+
return nil if manifests.empty?
|
|
923
|
+
|
|
924
|
+
selected = find_amd64_manifest(manifests) || manifests.first
|
|
925
|
+
selected&.dig("digest") || selected&.dig(:digest)
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
sig { params(manifests: T.untyped).returns(T.untyped) }
|
|
929
|
+
def find_amd64_manifest(manifests)
|
|
930
|
+
manifests.find do |m|
|
|
931
|
+
platform = m["platform"] || m[:platform] || {}
|
|
932
|
+
(platform["architecture"] || platform[:architecture]) == "amd64"
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# Validates that all platforms from the current tag are present in the
|
|
937
|
+
# candidate tag and that each platform's image was built at the same time
|
|
938
|
+
# (within tolerance) or newer. For single-platform current tags, falls
|
|
939
|
+
# back to simple timestamp comparison.
|
|
940
|
+
sig do
|
|
941
|
+
params(
|
|
942
|
+
candidate_tag: Dependabot::Docker::Tag,
|
|
943
|
+
current_tag: Dependabot::Docker::Tag
|
|
944
|
+
).returns(T::Boolean)
|
|
945
|
+
end
|
|
946
|
+
def validate_candidate_platforms(candidate_tag, current_tag)
|
|
947
|
+
current_platforms = fetch_manifest_platforms(current_tag.name)
|
|
948
|
+
|
|
949
|
+
# Single-platform current tag — fall back to simple timestamp comparison
|
|
950
|
+
return candidate_newer_by_created_date?(candidate_tag, current_tag) if current_platforms.nil?
|
|
951
|
+
|
|
952
|
+
candidate_platforms = fetch_manifest_platforms(candidate_tag.name)
|
|
953
|
+
|
|
954
|
+
# Candidate is single-platform but current is multi-platform
|
|
955
|
+
if candidate_platforms.nil?
|
|
956
|
+
Dependabot.logger.info(
|
|
957
|
+
"Platform validation: #{candidate_tag.name} is single-platform " \
|
|
958
|
+
"but #{current_tag.name} is multi-platform"
|
|
959
|
+
)
|
|
960
|
+
return false
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# Check all current platforms exist in candidate
|
|
964
|
+
current_keys = current_platforms.to_set { |p| platform_key(p) }
|
|
965
|
+
candidate_keys = candidate_platforms.to_set { |p| platform_key(p) }
|
|
966
|
+
missing = current_keys - candidate_keys
|
|
967
|
+
|
|
968
|
+
unless missing.empty?
|
|
969
|
+
Dependabot.logger.info(
|
|
970
|
+
"Platform validation: #{candidate_tag.name} missing platforms: #{missing.to_a.join(', ')}"
|
|
971
|
+
)
|
|
972
|
+
return false
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# Validate timestamps for each platform
|
|
976
|
+
validate_platform_timestamps(candidate_tag, current_tag, current_keys)
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
sig do
|
|
980
|
+
params(
|
|
981
|
+
candidate_tag: Dependabot::Docker::Tag,
|
|
982
|
+
current_tag: Dependabot::Docker::Tag,
|
|
983
|
+
platform_keys: T::Set[String]
|
|
984
|
+
).returns(T::Boolean)
|
|
985
|
+
end
|
|
986
|
+
def validate_platform_timestamps(candidate_tag, current_tag, platform_keys)
|
|
987
|
+
candidate_timestamps = fetch_all_platform_timestamps(candidate_tag.name)
|
|
988
|
+
current_timestamps = fetch_all_platform_timestamps(current_tag.name)
|
|
989
|
+
|
|
990
|
+
platform_keys.all? do |key|
|
|
991
|
+
candidate_time = candidate_timestamps[key]
|
|
992
|
+
current_time = current_timestamps[key]
|
|
993
|
+
|
|
994
|
+
# Both nil → trust semver
|
|
995
|
+
next true if candidate_time.nil? && current_time.nil?
|
|
996
|
+
# Only candidate nil → can't confirm, conservative fail
|
|
997
|
+
next false if candidate_time.nil?
|
|
998
|
+
# Only current nil → trust semver
|
|
999
|
+
next true if current_time.nil?
|
|
1000
|
+
|
|
1001
|
+
candidate_time >= (current_time - PLATFORM_TIMESTAMP_TOLERANCE_SECONDS)
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
sig do
|
|
1006
|
+
params(
|
|
1007
|
+
candidate_tag: Dependabot::Docker::Tag,
|
|
1008
|
+
current_tag: Dependabot::Docker::Tag
|
|
1009
|
+
).returns(T::Boolean)
|
|
1010
|
+
end
|
|
1011
|
+
def candidate_newer_by_created_date?(candidate_tag, current_tag)
|
|
1012
|
+
candidate_created = fetch_image_config_created(candidate_tag.name)
|
|
1013
|
+
current_created = fetch_image_config_created(current_tag.name)
|
|
1014
|
+
|
|
1015
|
+
# If both timestamps are unavailable, trust semver ordering
|
|
1016
|
+
return true if candidate_created.nil? && current_created.nil?
|
|
1017
|
+
|
|
1018
|
+
# If only the candidate's timestamp is unavailable, we can't confirm it's newer
|
|
1019
|
+
return false if candidate_created.nil?
|
|
1020
|
+
|
|
1021
|
+
# If only the current tag's timestamp is unavailable, trust semver ordering
|
|
1022
|
+
return true if current_created.nil?
|
|
1023
|
+
|
|
1024
|
+
candidate_created > current_created
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# Fetches the platform entries from a manifest list for a given tag.
|
|
1028
|
+
# Returns nil if the tag is a single-platform image (not a manifest list).
|
|
1029
|
+
sig { params(tag_name: String).returns(T.nilable(T::Array[T::Hash[String, T.untyped]])) }
|
|
1030
|
+
def fetch_manifest_platforms(tag_name)
|
|
1031
|
+
return manifest_platforms_cache[tag_name] if manifest_platforms_cache.key?(tag_name)
|
|
1032
|
+
|
|
1033
|
+
platforms = fetch_manifest_platforms_from_registry(tag_name)
|
|
1034
|
+
manifest_platforms_cache[tag_name] = platforms
|
|
1035
|
+
platforms
|
|
1036
|
+
rescue *transient_docker_errors, DockerRegistry2::RegistryAuthenticationException,
|
|
1037
|
+
RestClient::Forbidden, JSON::ParserError => e
|
|
1038
|
+
Dependabot.logger.info(
|
|
1039
|
+
"Failed to fetch manifest platforms for #{docker_repo_name}:#{tag_name}: #{e.message}"
|
|
1040
|
+
)
|
|
1041
|
+
manifest_platforms_cache[tag_name] = nil
|
|
1042
|
+
nil
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
sig { params(tag_name: String).returns(T.nilable(T::Array[T::Hash[String, T.untyped]])) }
|
|
1046
|
+
def fetch_manifest_platforms_from_registry(tag_name)
|
|
1047
|
+
manifest = with_retries(max_attempts: 3, errors: transient_docker_errors) do
|
|
1048
|
+
docker_registry_client.manifest(docker_repo_name, tag_name)
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
media_type = manifest["mediaType"] || manifest[:mediaType]
|
|
1052
|
+
return nil unless MANIFEST_LIST_TYPES.include?(media_type)
|
|
1053
|
+
|
|
1054
|
+
manifests = manifest["manifests"] || manifest[:manifests] || []
|
|
1055
|
+
|
|
1056
|
+
# Filter to actual image manifests (exclude attestations/signatures)
|
|
1057
|
+
manifests.filter_map { |m| extract_platform(m) }
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
sig { params(manifest_entry: T.untyped).returns(T.nilable(T::Hash[String, T.untyped])) }
|
|
1061
|
+
def extract_platform(manifest_entry)
|
|
1062
|
+
platform = manifest_entry["platform"] || manifest_entry[:platform]
|
|
1063
|
+
return unless platform
|
|
1064
|
+
|
|
1065
|
+
os = platform["os"] || platform[:os]
|
|
1066
|
+
arch = platform["architecture"] || platform[:architecture]
|
|
1067
|
+
return unless os && arch
|
|
1068
|
+
|
|
1069
|
+
platform
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
# Builds a normalized string key from a platform hash, e.g. "linux/amd64" or "linux/arm64/v8"
|
|
1073
|
+
sig { params(platform: T::Hash[T.any(String, Symbol), T.untyped]).returns(String) }
|
|
1074
|
+
def platform_key(platform)
|
|
1075
|
+
os = platform["os"] || platform[:os]
|
|
1076
|
+
arch = platform["architecture"] || platform[:architecture]
|
|
1077
|
+
variant = platform["variant"] || platform[:variant]
|
|
1078
|
+
|
|
1079
|
+
key = "#{os}/#{arch}"
|
|
1080
|
+
key = "#{key}/#{variant}" if variant
|
|
1081
|
+
key
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
# Fetches the created timestamp for every platform in a tag's manifest list.
|
|
1085
|
+
# Returns a Hash mapping platform key (e.g. "linux/amd64") to Time.
|
|
1086
|
+
sig { params(tag_name: String).returns(T::Hash[String, T.nilable(Time)]) }
|
|
1087
|
+
def fetch_all_platform_timestamps(tag_name)
|
|
1088
|
+
return T.must(platform_timestamps_cache[tag_name]) if platform_timestamps_cache.key?(tag_name)
|
|
1089
|
+
|
|
1090
|
+
timestamps = fetch_all_platform_timestamps_from_registry(tag_name)
|
|
1091
|
+
platform_timestamps_cache[tag_name] = timestamps
|
|
1092
|
+
timestamps
|
|
1093
|
+
rescue *transient_docker_errors, DockerRegistry2::RegistryAuthenticationException,
|
|
1094
|
+
RestClient::Forbidden, JSON::ParserError => e
|
|
1095
|
+
Dependabot.logger.info(
|
|
1096
|
+
"Failed to fetch platform timestamps for #{docker_repo_name}:#{tag_name}: #{e.message}"
|
|
1097
|
+
)
|
|
1098
|
+
platform_timestamps_cache[tag_name] = {}
|
|
1099
|
+
{}
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
sig { params(tag_name: String).returns(T::Hash[String, T.nilable(Time)]) }
|
|
1103
|
+
def fetch_all_platform_timestamps_from_registry(tag_name)
|
|
1104
|
+
manifest = with_retries(max_attempts: 3, errors: transient_docker_errors) do
|
|
1105
|
+
docker_registry_client.manifest(docker_repo_name, tag_name)
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
media_type = manifest["mediaType"] || manifest[:mediaType]
|
|
1109
|
+
return {} unless MANIFEST_LIST_TYPES.include?(media_type)
|
|
1110
|
+
|
|
1111
|
+
manifests = manifest["manifests"] || manifest[:manifests] || []
|
|
1112
|
+
collect_platform_timestamps(manifests)
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
sig { params(manifests: T.untyped).returns(T::Hash[String, T.nilable(Time)]) }
|
|
1116
|
+
def collect_platform_timestamps(manifests)
|
|
1117
|
+
timestamps = {}
|
|
1118
|
+
|
|
1119
|
+
manifests.each do |m|
|
|
1120
|
+
platform = extract_platform(m)
|
|
1121
|
+
next unless platform
|
|
1122
|
+
|
|
1123
|
+
digest = m["digest"] || m[:digest]
|
|
1124
|
+
next unless digest
|
|
1125
|
+
|
|
1126
|
+
key = platform_key(platform)
|
|
1127
|
+
timestamps[key] = fetch_platform_created_timestamp(digest)
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
timestamps
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
sig { params(platform_digest: String).returns(T.nilable(Time)) }
|
|
1134
|
+
def fetch_platform_created_timestamp(platform_digest)
|
|
1135
|
+
platform_manifest = with_retries(max_attempts: 3, errors: transient_docker_errors) do
|
|
1136
|
+
docker_registry_client.doget("v2/#{docker_repo_name}/manifests/#{platform_digest}")
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
parsed = JSON.parse(platform_manifest.body)
|
|
1140
|
+
config_digest = parsed.dig("config", "digest")
|
|
1141
|
+
return nil unless config_digest
|
|
1142
|
+
|
|
1143
|
+
parse_created_from_config_blob(config_digest)
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
sig { returns(T::Hash[String, T.nilable(T::Array[T::Hash[String, T.untyped]])]) }
|
|
1147
|
+
def manifest_platforms_cache
|
|
1148
|
+
@manifest_platforms_cache ||= T.let(
|
|
1149
|
+
{},
|
|
1150
|
+
T.nilable(T::Hash[String, T.nilable(T::Array[T::Hash[String, T.untyped]])])
|
|
1151
|
+
)
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
sig { returns(T::Hash[String, T::Hash[String, T.nilable(Time)]]) }
|
|
1155
|
+
def platform_timestamps_cache
|
|
1156
|
+
@platform_timestamps_cache ||= T.let(
|
|
1157
|
+
{},
|
|
1158
|
+
T.nilable(T::Hash[String, T::Hash[String, T.nilable(Time)]])
|
|
1159
|
+
)
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
sig { returns(T::Hash[String, T.nilable(Time)]) }
|
|
1163
|
+
def config_created_timestamps
|
|
1164
|
+
@config_created_timestamps ||= T.let(
|
|
1165
|
+
{},
|
|
1166
|
+
T.nilable(T::Hash[String, T.nilable(Time)])
|
|
1167
|
+
)
|
|
1168
|
+
end
|
|
688
1169
|
end
|
|
689
1170
|
# rubocop:enable Metrics/ClassLength
|
|
690
1171
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dependabot-docker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.364.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dependabot
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - '='
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.
|
|
18
|
+
version: 0.364.0
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - '='
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.364.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: debug
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -261,7 +261,7 @@ licenses:
|
|
|
261
261
|
- MIT
|
|
262
262
|
metadata:
|
|
263
263
|
bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
|
|
264
|
-
changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.
|
|
264
|
+
changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.364.0
|
|
265
265
|
rdoc_options: []
|
|
266
266
|
require_paths:
|
|
267
267
|
- lib
|