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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9b2bbaa23379e0a0182097bd32b0e2e0d2628a18e61c34946068c70bb1ec526
4
- data.tar.gz: a727fbf08f44bee5dce893eedb64dcf03f1c33e9cef368f2147e00dec608fc6a
3
+ metadata.gz: 62746febf2f7781d370bf9ae465d76c03ae5b8afb926d10eb0d27091ab0ac08c
4
+ data.tar.gz: a600410ab9c7b7d121c639898654f8a134033da58b6f98118c3da7bca9d75a4f
5
5
  SHA512:
6
- metadata.gz: ae0183e506e3b21ea05a9fc8ebd01e7fe3c4d3c447f4ff7e7e3037eb4bff4614fe448bd81a5690d5d9d6d48248976db53554dad2d9da9aea3f39ccefcbba45bb
7
- data.tar.gz: 0605ddcfc7e3957311ddd65ca6a76802ae61684e68915512952e412d49325238533b978fa145076aec743f5a722e24cd63b6c3a066339817e6bd39f6827cccba
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
- latest_tag = candidate_tags.last
158
- return version_tag unless latest_tag
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
- return latest_tag if latest_tag.same_precision?(version_tag)
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
- latest_same_precision_tag = remove_precision_changes(candidate_tags, version_tag).last
163
- return latest_tag unless latest_same_precision_tag
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
- latest_same_precision_digest = digest_of(latest_same_precision_tag.name)
166
- latest_digest = digest_of(latest_tag.name)
286
+ return candidate unless best_same_precision
167
287
 
168
- # NOTE: Some registries don't provide digests (the API documents them as
169
- # optional: https://docs.docker.com/registry/spec/api/#content-digests).
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 latest_same_precision_digest == latest_digest && latest_same_precision_tag.same_but_less_precise?(latest_tag)
180
- latest_same_precision_tag
291
+ if same_precision_digest == candidate_digest &&
292
+ best_same_precision.same_but_less_precise?(candidate)
293
+ best_same_precision
181
294
  else
182
- latest_tag
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
- number: /^\d+$/,
338
- semver: /^\d+\.\d+$/,
339
- v_prefix: /^v\d+/,
340
- version_marker: /^(rc|jre)$/,
341
- prerelease: /^(?=.*\d)(?=.*[a-z])[a-z\d]+$/i,
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 do |tag_a, tag_b|
605
- if comparable_version_from(tag_a) > comparable_version_from(tag_b)
606
- 1
607
- elsif comparable_version_from(tag_a) < comparable_version_from(tag_b)
608
- -1
609
- elsif tag_a.same_precision?(version_tag)
610
- 1
611
- elsif tag_b.same_precision?(version_tag)
612
- -1
613
- else
614
- 0
615
- end
616
- end
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.363.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.363.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.363.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.363.0
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