Module: StillActive::DepsDevClient

Extended by:
DepsDevClient
Included in:
DepsDevClient
Defined in:
lib/still_active/deps_dev_client.rb

Constant Summary collapse

BASE_URI =
URI("https://api.deps.dev/")
ADVISORY_CANARY =

A known-vulnerable package used to canary the advisoryKeys schema. deps.dev is an explicitly ALPHA API (v3alpha), and every cross-ecosystem vulnerability count flows through the advisoryKeys field with a field-level degrade to [] -- so a rename or drop of that field would silently turn every count to 0 and read a known-vulnerable package as clean (exit 0). django 3.0.0 carries 30+ permanent advisories; if the canary returns none, the schema drifted.

{system: "pypi", name: "django", version: "3.0.0"}.freeze

Instance Method Summary collapse

Instance Method Details

#advisory_detail(advisory_id:) ⇒ Object



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/still_active/deps_dev_client.rb', line 154

def advisory_detail(advisory_id:)
  return if advisory_id.nil?

  path = "/v3alpha/advisories/#{encode(advisory_id)}"
  body = HttpHelper.get_json(BASE_URI, path)
  return if body.nil?

  {
    id: body.dig("advisoryKey", "id"),
    url: body["url"],
    title: body["title"],
    # deps.dev's v3alpha returns aliases as bare id strings (["CVE-..."]);
    # tolerate the legacy object shape ({"id":...}) too since it's an alpha API.
    aliases: Array(body["aliases"]).filter_map { |a| normalize_alias(a) },
    cvss3_score: body["cvss3Score"],
    cvss3_vector: body["cvss3Vector"],
    cvss2_score: body["cvss2Score"],
    source: "deps.dev"
  }
end

#advisory_schema_ok?Boolean

Is deps.dev still returning advisories in the shape we parse? False when the canary comes back empty (schema drift) or unreachable (can't confirm). The caller warns loudly rather than presenting a possibly-understated "all clear".

Returns:

  • (Boolean)


23
24
25
26
# File 'lib/still_active/deps_dev_client.rb', line 23

def advisory_schema_ok?
  info = version_info(gem_name: ADVISORY_CANARY[:name], version: ADVISORY_CANARY[:version], system: ADVISORY_CANARY[:system])
  !(info.nil? || info[:advisory_keys].empty?)
end

#default_version_info(name:, system: :rubygems) ⇒ Object

The package's default version and its release date: { version:, published_at: }, or nil. deps.dev's default version is the latest stable; when none is flagged (all pre-release), the newest publishedAt stands in so a still-active package isn't mis-read as dormant. The version is exposed (not just the date) so a caller can recover the package's repo link from it when an exact locked version isn't indexed (yanked/normalization mismatch).



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/still_active/deps_dev_client.rb', line 70

def default_version_info(name:, system: :rubygems)
  return if name.nil?

  path = "/v3alpha/systems/#{encode(system)}/packages/#{encode(name)}"
  body = HttpHelper.get_json(BASE_URI, path)
  return if body.nil?

  versions = body["versions"]
  return unless versions.is_a?(Array) && !versions.empty?

  # deps.dev's `isDefault` is NOT reliably the latest release: cargo/wasi flags
  # 0.7.0 (2019) while 0.14.7 (2025) ships, and pypi/httpx flags a 1.0.0.dev3
  # prerelease over the 0.28.1 stable. Trusting it reads an active package as
  # years-stale (a false SA002 "abandoned") and paints a downgrade as an
  # upgrade. So rank by version and take the newest STABLE release; fall back to
  # isDefault, then newest-by-date, only when no stable version parses (a
  # genuinely prerelease-only package still reads active, not dormant).
  entry = latest_stable_version(versions) ||
    versions.find { |v| v.is_a?(Hash) && v["isDefault"] } ||
    newest_version(versions)
  return if entry.nil?

  {version: entry.dig("versionKey", "version"), published_at: entry["publishedAt"]}
end

#latest_release_date(name:, system: :rubygems) ⇒ Object

The package's most recent release date (ISO8601 string), or nil. This is the cross-ecosystem freshness signal, and deps.dev's publishedAt is more reliable than ecosyste.ms's latest_release_published_at, which can lag badly (mpmath: 2023 vs 2026).



114
115
116
# File 'lib/still_active/deps_dev_client.rb', line 114

def latest_release_date(name:, system: :rubygems)
  default_version_info(name: name, system: system)&.dig(:published_at)
end

#latest_stable_version(versions) ⇒ Object

The newest non-prerelease version by version number (not publishedAt: a backported patch on an old line can post-date the latest major). nil when no version parses as a stable release.



98
99
100
101
102
103
104
105
106
107
108
# File 'lib/still_active/deps_dev_client.rb', line 98

def latest_stable_version(versions)
  versions
    .filter_map do |v|
      next unless v.is_a?(Hash) && !v["isDeprecated"]

      gem_version = VersionHelper.comparable(v.dig("versionKey", "version"))
      [gem_version, v] if gem_version && !gem_version.prerelease?
    end
    .max_by(&:first)
    &.last
end

#project_scorecard(project_id:) ⇒ Object



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/still_active/deps_dev_client.rb', line 118

def project_scorecard(project_id:)
  return if project_id.nil?

  path = "/v3alpha/projects/#{encode(project_id)}"
  body = HttpHelper.get_json(BASE_URI, path)
  return if body.nil?

  scorecard = body["scorecard"]
  return if scorecard.nil?

  {
    score: scorecard["overallScore"],
    date: scorecard["date"],
    # The "Maintained" sub-check (0-10) scores recent commit and issue
    # activity directly -- still_active's core question -- so we surface it
    # alongside the aggregate. nil when the check is absent (never 0, which
    # would read as "unmaintained" rather than "not measured").
    maintained: maintained_check_score(scorecard)
  }
end

#target_frameworks(name:, version:) ⇒ Object

The NuGet target framework monikers a version declares (net6.0, net45, netstandard2.0, ...), or [] when unknown. On the NuGet-specific :requirements endpoint, not the version endpoint. The language-runtime ceiling (SA009) reads these to detect a package that targets only EOL .NET runtimes. Degrades to [] on 404/failure, never raising.



144
145
146
147
148
149
150
151
152
# File 'lib/still_active/deps_dev_client.rb', line 144

def target_frameworks(name:, version:)
  return [] if name.nil? || version.nil?

  path = "/v3alpha/systems/nuget/packages/#{encode(name)}/versions/#{encode(version)}:requirements"
  body = HttpHelper.get_json(BASE_URI, path)
  return [] if body.nil?

  Array(body.dig("nuget", "targetFrameworks")).grep(String)
end

#version_info(gem_name:, version:, system: :rubygems) ⇒ Object

system is the deps.dev package system, lowercased: rubygems, npm, pypi, cargo, go, maven, nuget. It matches the ecosystem symbol SbomReader emits, so a cross-ecosystem caller threads it straight through. An unknown system 404s and degrades to nil (HttpHelper swallows 404), never raising.



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/still_active/deps_dev_client.rb', line 32

def version_info(gem_name:, version:, system: :rubygems)
  return if gem_name.nil? || version.nil?

  path = "/v3alpha/systems/#{encode(system)}/packages/#{encode(gem_name)}/versions/#{encode(version)}"
  body = HttpHelper.get_json(BASE_URI, path)
  return if body.nil?

  {
    advisory_keys: body.dig("advisoryKeys")&.map { |a| a["id"] } || [],
    project_id: extract_project_id(body),
    # The locked version's release date -- the cross-ecosystem libyear input
    # (paired with the package's latest-release date). Already in this response,
    # so no extra fetch; nil when the feed omits it.
    published_at: body["publishedAt"],
    # The pinned version's SPDX licences, the same array shape RubyGems serves
    # (verified across npm, pypi, cargo, go and maven). Also already in this
    # response, so licence parity with the native path costs no extra request.
    # Always an array, so a caller can't mistake "no licence declared" for a
    # failed lookup.
    licenses: body["licenses"] || [],
    # The maintainer's own deprecation declaration, and the message they left
    # with it (often naming the replacement: left-pad's reads "use
    # String.prototype.padStart()"). This is a declared fact from the registry,
    # not a heuristic inferred from dates, and it rides in the same response as
    # the advisories and the release date, so it costs no extra request.
    # Populated for npm today; other ecosystems mostly lack a deprecation
    # mechanism, and any deps.dev later fills in lights up with no code change.
    deprecated: body["isDeprecated"] == true,
    deprecation_reason: presence(body["deprecatedReason"])
  }
end