purl 1.5.2 → 1.7.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.
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purl
4
+ # Formats security advisory lookup results for human-readable display
5
+ class AdvisoryFormatter
6
+ def initialize
7
+ end
8
+
9
+ # Format advisory lookup results for console output
10
+ #
11
+ # @param advisories [Array<Hash>] Array of advisory hashes from Purl::Advisory#lookup
12
+ # @param purl [PackageURL] Original PURL object
13
+ # @return [String] Formatted output string
14
+ def format_text(advisories, purl)
15
+ return "No security advisories found" if advisories.nil? || advisories.empty?
16
+
17
+ output = []
18
+ output << "Security Advisories for #{purl.to_s}"
19
+ output << "=" * 80
20
+ output << ""
21
+
22
+ advisories.each_with_index do |advisory, index|
23
+ output << format_advisory_text(advisory, index + 1)
24
+ output << "" unless index == advisories.length - 1
25
+ end
26
+
27
+ output << ""
28
+ output << "Total advisories found: #{advisories.length}"
29
+
30
+ output.join("\n")
31
+ end
32
+
33
+ # Format advisory lookup results for JSON output
34
+ #
35
+ # @param advisories [Array<Hash>] Array of advisory hashes from Purl::Advisory#lookup
36
+ # @param purl [PackageURL] Original PURL object
37
+ # @return [Hash] JSON-ready hash structure
38
+ def format_json(advisories, purl)
39
+ {
40
+ success: true,
41
+ purl: purl.to_s,
42
+ advisories: advisories,
43
+ count: advisories.length
44
+ }
45
+ end
46
+
47
+ private
48
+
49
+ def format_advisory_text(advisory, number)
50
+ output = []
51
+
52
+ # Header with identifiers
53
+ identifiers = format_identifiers(advisory)
54
+ output << "Advisory ##{number}: #{advisory[:title]}"
55
+ output << "Identifiers: #{identifiers}" if identifiers
56
+
57
+ # Severity and scoring
58
+ if advisory[:severity] || advisory[:cvss_score]
59
+ severity_line = []
60
+ severity_line << "Severity: #{advisory[:severity]}" if advisory[:severity]
61
+ if advisory[:cvss_score] && advisory[:cvss_score] > 0
62
+ severity_line << "CVSS Score: #{advisory[:cvss_score]}"
63
+ end
64
+ output << severity_line.join(" | ")
65
+ end
66
+
67
+ # Description
68
+ if advisory[:description]
69
+ output << ""
70
+ output << "Description:"
71
+ output << wrap_text(advisory[:description], 78, " ")
72
+ end
73
+
74
+ # Affected packages
75
+ if advisory[:affected_packages] && !advisory[:affected_packages].empty?
76
+ output << ""
77
+ output << "Affected Packages:"
78
+ advisory[:affected_packages].each do |pkg|
79
+ version_info = []
80
+ version_info << " Package: #{pkg[:ecosystem]}/#{pkg[:name]}"
81
+ version_info << " Vulnerable: #{pkg[:vulnerable_version_range]}" if pkg[:vulnerable_version_range]
82
+ version_info << " Patched: #{pkg[:first_patched_version]}" if pkg[:first_patched_version]
83
+ output.concat(version_info)
84
+ end
85
+ end
86
+
87
+ # Source and dates
88
+ output << ""
89
+ source_info = []
90
+ source_info << "Source: #{advisory[:source_kind]}" if advisory[:source_kind]
91
+ source_info << "Origin: #{advisory[:origin]}" if advisory[:origin]
92
+ source_info << "Published: #{format_date(advisory[:published_at])}" if advisory[:published_at]
93
+ output << source_info.join(" | ") unless source_info.empty?
94
+
95
+ if advisory[:withdrawn_at]
96
+ output << "Status: WITHDRAWN on #{format_date(advisory[:withdrawn_at])}"
97
+ end
98
+
99
+ # URLs
100
+ urls = []
101
+ urls << "Advisory URL: #{advisory[:url]}" if advisory[:url]
102
+ urls << "Repository: #{advisory[:repository_url]}" if advisory[:repository_url]
103
+ output.concat(urls) unless urls.empty?
104
+
105
+ # References
106
+ if advisory[:references] && !advisory[:references].empty? && advisory[:references].any? { |ref| ref.is_a?(String) && !ref.empty? }
107
+ output << ""
108
+ output << "References:"
109
+ advisory[:references].each do |ref|
110
+ output << " - #{ref}" if ref.is_a?(String) && !ref.empty?
111
+ end
112
+ end
113
+
114
+ output.join("\n")
115
+ end
116
+
117
+ def format_identifiers(advisory)
118
+ return nil unless advisory[:identifiers] && !advisory[:identifiers].empty?
119
+ advisory[:identifiers].join(", ")
120
+ end
121
+
122
+ def format_date(date_string)
123
+ return nil unless date_string
124
+ # Keep ISO format for now, could parse and reformat if needed
125
+ date_string
126
+ end
127
+
128
+ def wrap_text(text, width, indent = "")
129
+ return text unless text
130
+
131
+ # Split on double newlines to preserve paragraph breaks
132
+ paragraphs = text.split(/\n\n+/)
133
+
134
+ wrapped_paragraphs = paragraphs.map do |paragraph|
135
+ # Replace single newlines within a paragraph with spaces
136
+ # but preserve intentional formatting (like lists)
137
+ paragraph = paragraph.gsub(/\n/, " ")
138
+
139
+ # Wrap the paragraph
140
+ words = paragraph.split(/\s+/)
141
+ lines = []
142
+ current_line = indent.dup
143
+
144
+ words.each do |word|
145
+ if (current_line + word).length > width
146
+ lines << current_line.rstrip
147
+ current_line = indent + word + " "
148
+ else
149
+ current_line += word + " "
150
+ end
151
+ end
152
+
153
+ lines << current_line.rstrip unless current_line.strip.empty?
154
+ lines.join("\n")
155
+ end
156
+
157
+ wrapped_paragraphs.join("\n#{indent}\n")
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purl
4
+ class DownloadURL
5
+ DOWNLOAD_PATTERNS = {
6
+ "gem" => {
7
+ base_url: "https://rubygems.org/downloads",
8
+ pattern: ->(purl) { "#{purl.name}-#{purl.version}.gem" }
9
+ },
10
+ "npm" => {
11
+ base_url: "https://registry.npmjs.org",
12
+ pattern: ->(purl) do
13
+ # npm: /{name}/-/{basename}-{version}.tgz
14
+ # For scoped packages: /@scope/name/-/name-version.tgz
15
+ basename = purl.name.split("/").last
16
+ if purl.namespace
17
+ "#{purl.namespace}/#{purl.name}/-/#{basename}-#{purl.version}.tgz"
18
+ else
19
+ "#{purl.name}/-/#{purl.name}-#{purl.version}.tgz"
20
+ end
21
+ end
22
+ },
23
+ "cargo" => {
24
+ base_url: "https://static.crates.io/crates",
25
+ pattern: ->(purl) { "#{purl.name}/#{purl.name}-#{purl.version}.crate" }
26
+ },
27
+ "nuget" => {
28
+ base_url: "https://api.nuget.org/v3-flatcontainer",
29
+ pattern: ->(purl) do
30
+ name_lower = purl.name.downcase
31
+ "#{name_lower}/#{purl.version}/#{name_lower}.#{purl.version}.nupkg"
32
+ end
33
+ },
34
+ "hex" => {
35
+ base_url: "https://repo.hex.pm/tarballs",
36
+ pattern: ->(purl) { "#{purl.name}-#{purl.version}.tar" }
37
+ },
38
+ "hackage" => {
39
+ base_url: "https://hackage.haskell.org/package",
40
+ pattern: ->(purl) { "#{purl.name}-#{purl.version}/#{purl.name}-#{purl.version}.tar.gz" }
41
+ },
42
+ "pub" => {
43
+ base_url: "https://pub.dev/packages",
44
+ pattern: ->(purl) { "#{purl.name}/versions/#{purl.version}.tar.gz" }
45
+ },
46
+ "golang" => {
47
+ base_url: "https://proxy.golang.org",
48
+ pattern: ->(purl) do
49
+ # Go module proxy requires encoding capital letters as !lowercase
50
+ full_path = purl.namespace ? "#{purl.namespace}/#{purl.name}" : purl.name
51
+ encoded = full_path.gsub(/[A-Z]/) { |s| "!#{s.downcase}" }
52
+ "#{encoded}/@v/#{purl.version}.zip"
53
+ end
54
+ },
55
+ "maven" => {
56
+ base_url: "https://repo.maven.apache.org/maven2",
57
+ pattern: ->(purl) do
58
+ # Maven: /{group_path}/{artifact}/{version}/{artifact}-{version}.jar
59
+ # group_id uses dots, path uses slashes
60
+ group_path = purl.namespace.gsub(".", "/")
61
+ "#{group_path}/#{purl.name}/#{purl.version}/#{purl.name}-#{purl.version}.jar"
62
+ end
63
+ },
64
+ "cran" => {
65
+ base_url: "https://cran.r-project.org/src/contrib",
66
+ pattern: ->(purl) { "#{purl.name}_#{purl.version}.tar.gz" }
67
+ },
68
+ "bioconductor" => {
69
+ base_url: "https://bioconductor.org/packages/release/bioc/src/contrib",
70
+ pattern: ->(purl) { "#{purl.name}_#{purl.version}.tar.gz" }
71
+ },
72
+ "clojars" => {
73
+ base_url: "https://repo.clojars.org",
74
+ pattern: ->(purl) do
75
+ # Clojars uses maven-style paths
76
+ # namespace is group_id, name is artifact_id
77
+ # If no namespace, group_id = artifact_id
78
+ group_id = purl.namespace || purl.name
79
+ artifact_id = purl.name
80
+ group_path = group_id.gsub(".", "/")
81
+ "#{group_path}/#{artifact_id}/#{purl.version}/#{artifact_id}-#{purl.version}.jar"
82
+ end
83
+ },
84
+ "elm" => {
85
+ base_url: "https://github.com",
86
+ pattern: ->(purl) do
87
+ # Elm packages are hosted on GitHub
88
+ # namespace/name format maps to GitHub user/repo
89
+ return nil unless purl.namespace
90
+ "#{purl.namespace}/#{purl.name}/archive/#{purl.version}.zip"
91
+ end
92
+ },
93
+ "github" => {
94
+ base_url: "https://github.com",
95
+ pattern: ->(purl) do
96
+ return nil unless purl.namespace
97
+ "#{purl.namespace}/#{purl.name}/archive/refs/tags/#{purl.version}.tar.gz"
98
+ end
99
+ },
100
+ "gitlab" => {
101
+ base_url: "https://gitlab.com",
102
+ pattern: ->(purl) do
103
+ return nil unless purl.namespace
104
+ "#{purl.namespace}/#{purl.name}/-/archive/#{purl.version}/#{purl.name}-#{purl.version}.tar.gz"
105
+ end
106
+ },
107
+ "bitbucket" => {
108
+ base_url: "https://bitbucket.org",
109
+ pattern: ->(purl) do
110
+ return nil unless purl.namespace
111
+ "#{purl.namespace}/#{purl.name}/get/#{purl.version}.tar.gz"
112
+ end
113
+ },
114
+ "luarocks" => {
115
+ base_url: "https://luarocks.org/manifests",
116
+ pattern: ->(purl) do
117
+ return nil unless purl.namespace
118
+ "#{purl.namespace}/#{purl.name}-#{purl.version}.src.rock"
119
+ end
120
+ },
121
+ "swift" => {
122
+ base_url: nil,
123
+ pattern: ->(purl) do
124
+ # Swift namespace is like "github.com/owner", name is repo
125
+ # e.g. pkg:swift/github.com/Alamofire/Alamofire@5.6.4
126
+ return nil unless purl.namespace
127
+ parts = purl.namespace.split("/", 2)
128
+ host = parts[0]
129
+ owner = parts[1]
130
+ return nil unless host && owner
131
+
132
+ case host
133
+ when "github.com"
134
+ "https://github.com/#{owner}/#{purl.name}/archive/refs/tags/#{purl.version}.tar.gz"
135
+ when "gitlab.com"
136
+ "https://gitlab.com/#{owner}/#{purl.name}/-/archive/#{purl.version}/#{purl.name}-#{purl.version}.tar.gz"
137
+ when "bitbucket.org"
138
+ "https://bitbucket.org/#{owner}/#{purl.name}/get/#{purl.version}.tar.gz"
139
+ end
140
+ end
141
+ },
142
+ "composer" => {
143
+ base_url: nil,
144
+ pattern: ->(purl) { nil },
145
+ note: "Composer packages are downloaded from source repositories, not Packagist"
146
+ },
147
+ "cocoapods" => {
148
+ base_url: nil,
149
+ pattern: ->(purl) { nil },
150
+ note: "CocoaPods packages are downloaded from source repositories"
151
+ }
152
+ }.freeze
153
+
154
+ def self.generate(purl, base_url: nil)
155
+ new(purl).generate(base_url: base_url)
156
+ end
157
+
158
+ # Types that require a namespace for download URLs
159
+ NAMESPACE_REQUIRED_TYPES = %w[maven elm github gitlab bitbucket luarocks swift].freeze
160
+
161
+ def self.supported_types
162
+ DOWNLOAD_PATTERNS.keys.select do |k|
163
+ pattern = DOWNLOAD_PATTERNS[k]
164
+ # Skip types with notes (they're not really supported)
165
+ next false if pattern[:note]
166
+
167
+ # Test with appropriate namespace for types that need it
168
+ namespace = if NAMESPACE_REQUIRED_TYPES.include?(k)
169
+ k == "swift" ? "github.com/test" : "test"
170
+ end
171
+ begin
172
+ result = pattern[:pattern].call(Purl::PackageURL.new(type: k, name: "test", version: "1.0", namespace: namespace))
173
+ !result.nil?
174
+ rescue
175
+ false
176
+ end
177
+ end.sort
178
+ end
179
+
180
+ def self.supports?(type)
181
+ pattern = DOWNLOAD_PATTERNS[type.to_s.downcase]
182
+ return false unless pattern
183
+ # Types with base_url are supported, or types that return full URLs (like swift)
184
+ return true if pattern[:base_url]
185
+ # Check if this type returns full URLs by testing with a sample
186
+ return false if pattern[:note] # Has a note means it's not really supported
187
+ true
188
+ end
189
+
190
+ def initialize(purl)
191
+ @purl = purl
192
+ end
193
+
194
+ def generate(base_url: nil)
195
+ unless @purl.version
196
+ raise MissingVersionError.new(
197
+ "Download URL requires a version",
198
+ type: @purl.type
199
+ )
200
+ end
201
+
202
+ pattern_config = DOWNLOAD_PATTERNS[@purl.type.downcase]
203
+
204
+ unless pattern_config
205
+ raise UnsupportedTypeError.new(
206
+ "No download URL pattern defined for type '#{@purl.type}'. Supported types: #{self.class.supported_types.join(", ")}",
207
+ type: @purl.type,
208
+ supported_types: self.class.supported_types
209
+ )
210
+ end
211
+
212
+ # Check for repository_url qualifier in the PURL
213
+ qualifier_base_url = @purl.qualifiers&.dig("repository_url")
214
+
215
+ # Generate the path/URL from the pattern
216
+ path = pattern_config[:pattern].call(@purl)
217
+
218
+ if path.nil?
219
+ raise UnsupportedTypeError.new(
220
+ "Could not generate download URL for '#{@purl.type}'. #{pattern_config[:note]}",
221
+ type: @purl.type,
222
+ supported_types: self.class.supported_types
223
+ )
224
+ end
225
+
226
+ # If the pattern returns a full URL, use it directly
227
+ return path if path.start_with?("http://", "https://")
228
+
229
+ # For relative paths, we need a base_url
230
+ effective_base_url = base_url || qualifier_base_url || pattern_config[:base_url]
231
+
232
+ unless effective_base_url
233
+ raise UnsupportedTypeError.new(
234
+ "Download URLs are not available for type '#{@purl.type}'. #{pattern_config[:note]}",
235
+ type: @purl.type,
236
+ supported_types: self.class.supported_types
237
+ )
238
+ end
239
+
240
+ "#{effective_base_url}/#{path}"
241
+ end
242
+
243
+ attr_reader :purl
244
+ end
245
+
246
+ # Error for missing version
247
+ class MissingVersionError < Error
248
+ attr_reader :type
249
+
250
+ def initialize(message, type: nil)
251
+ @type = type
252
+ super(message)
253
+ end
254
+ end
255
+
256
+ # Add download URL generation methods to PackageURL
257
+ class PackageURL
258
+ def download_url(base_url: nil)
259
+ DownloadURL.generate(self, base_url: base_url)
260
+ end
261
+
262
+ def supports_download_url?
263
+ DownloadURL.supports?(type)
264
+ end
265
+ end
266
+ end
data/lib/purl/lookup.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
+ require "timeout"
6
7
 
7
8
  module Purl
8
9
  # Provides lookup functionality for packages using the ecosyste.ms API
@@ -32,28 +33,45 @@ module Purl
32
33
  def package_info(purl)
33
34
  purl_obj = purl.is_a?(PackageURL) ? purl : PackageURL.parse(purl.to_s)
34
35
 
35
- # Make API request to ecosyste.ms
36
+ # Try package lookup first
36
37
  uri = URI("#{ECOSYSTE_MS_API_BASE}/packages/lookup")
37
38
  uri.query = URI.encode_www_form({ purl: purl_obj.to_s })
38
39
 
39
40
  response_data = make_request(uri)
40
41
 
41
- return nil unless response_data.is_a?(Array) && response_data.length > 0
42
+ if response_data.is_a?(Array) && response_data.length > 0
43
+ package_data = response_data[0]
44
+
45
+ result = {
46
+ purl: purl_obj.to_s,
47
+ package: extract_package_info(package_data)
48
+ }
49
+
50
+ # If PURL has a version and we have a versions_url, fetch version-specific details
51
+ if purl_obj.version && package_data["versions_url"]
52
+ version_info = fetch_version_info(package_data["versions_url"], purl_obj.version)
53
+ result[:version] = version_info if version_info
54
+ end
55
+
56
+ return result
57
+ end
42
58
 
43
- package_data = response_data[0]
59
+ # If no package found, try repository lookup
60
+ repo_uri = URI("https://repos.ecosyste.ms/api/v1/repositories/lookup")
61
+ repo_uri.query = URI.encode_www_form({ purl: purl_obj.to_s })
44
62
 
45
- result = {
46
- purl: purl_obj.to_s,
47
- package: extract_package_info(package_data)
48
- }
63
+ repo_data = make_request(repo_uri)
49
64
 
50
- # If PURL has a version and we have a versions_url, fetch version-specific details
51
- if purl_obj.version && package_data["versions_url"]
52
- version_info = fetch_version_info(package_data["versions_url"], purl_obj.version)
53
- result[:version] = version_info if version_info
65
+ if repo_data
66
+ result = {
67
+ purl: purl_obj.to_s,
68
+ repository: extract_repository_info(repo_data)
69
+ }
70
+
71
+ return result
54
72
  end
55
73
 
56
- result
74
+ nil
57
75
  end
58
76
 
59
77
  # Look up version information for a specific version of a package
@@ -102,7 +120,7 @@ module Purl
102
120
  end
103
121
  rescue JSON::ParserError => e
104
122
  raise LookupError, "Failed to parse API response: #{e.message}"
105
- rescue Net::TimeoutError, Net::OpenTimeout, Net::ReadTimeout => e
123
+ rescue Timeout::Error, Net::OpenTimeout, Net::ReadTimeout => e
106
124
  raise LookupError, "Request timeout: #{e.message}"
107
125
  rescue StandardError => e
108
126
  raise LookupError, "Lookup failed: #{e.message}"
@@ -183,6 +201,29 @@ module Purl
183
201
  }.compact # Remove nil values
184
202
  end
185
203
  end
204
+
205
+ def extract_repository_info(repo_data)
206
+ {
207
+ name: repo_data["name"],
208
+ full_name: repo_data["full_name"],
209
+ host: repo_data["host"],
210
+ description: repo_data["description"],
211
+ homepage: repo_data["homepage"],
212
+ url: repo_data["url"],
213
+ language: repo_data["language"],
214
+ license: repo_data["license"],
215
+ fork: repo_data["fork"],
216
+ archived: repo_data["archived"],
217
+ stars: repo_data["stargazers_count"],
218
+ forks: repo_data["forks_count"],
219
+ open_issues: repo_data["open_issues_count"],
220
+ default_branch: repo_data["default_branch"],
221
+ pushed_at: repo_data["pushed_at"],
222
+ created_at: repo_data["created_at"],
223
+ updated_at: repo_data["updated_at"],
224
+ topics: repo_data["topics"]
225
+ }
226
+ end
186
227
  end
187
228
 
188
229
  # Error raised when package lookup fails
@@ -14,6 +14,45 @@ module Purl
14
14
  def format_text(lookup_result, purl)
15
15
  return "Package not found" unless lookup_result
16
16
 
17
+ if lookup_result[:package]
18
+ format_package_text(lookup_result, purl)
19
+ elsif lookup_result[:repository]
20
+ format_repository_text(lookup_result, purl)
21
+ else
22
+ "No information found"
23
+ end
24
+ end
25
+
26
+ # Format package lookup results for JSON output
27
+ #
28
+ # @param lookup_result [Hash] Result from Purl::Lookup#package_info
29
+ # @param purl [PackageURL] Original PURL object
30
+ # @return [Hash] JSON-ready hash structure
31
+ def format_json(lookup_result, purl)
32
+ return {
33
+ success: false,
34
+ purl: purl.to_s,
35
+ error: "Package not found in ecosyste.ms database"
36
+ } unless lookup_result
37
+
38
+ result = {
39
+ success: true,
40
+ purl: purl.to_s
41
+ }
42
+
43
+ if lookup_result[:package]
44
+ result[:package] = lookup_result[:package]
45
+ result[:version] = lookup_result[:version] if lookup_result[:version]
46
+ elsif lookup_result[:repository]
47
+ result[:repository] = lookup_result[:repository]
48
+ end
49
+
50
+ result
51
+ end
52
+
53
+ private
54
+
55
+ def format_package_text(lookup_result, purl)
17
56
  package = lookup_result[:package]
18
57
  version_info = lookup_result[:version]
19
58
 
@@ -86,27 +125,44 @@ module Purl
86
125
  output.join("\n")
87
126
  end
88
127
 
89
- # Format package lookup results for JSON output
90
- #
91
- # @param lookup_result [Hash] Result from Purl::Lookup#package_info
92
- # @param purl [PackageURL] Original PURL object
93
- # @return [Hash] JSON-ready hash structure
94
- def format_json(lookup_result, purl)
95
- return {
96
- success: false,
97
- purl: purl.to_s,
98
- error: "Package not found in ecosyste.ms database"
99
- } unless lookup_result
100
-
101
- result = {
102
- success: true,
103
- purl: purl.to_s,
104
- package: lookup_result[:package]
105
- }
128
+ def format_repository_text(lookup_result, purl)
129
+ repository = lookup_result[:repository]
130
+
131
+ output = []
106
132
 
107
- result[:version] = lookup_result[:version] if lookup_result[:version]
133
+ # Repository header - map to package-like format
134
+ output << "Package: #{repository[:name]} (repository)"
135
+ output << "#{repository[:description]}" if repository[:description]
108
136
 
109
- result
137
+ # Repository stats section (maps to version info)
138
+ output << ""
139
+ output << "Version Information:"
140
+ output << " Default branch: #{repository[:default_branch]}" if repository[:default_branch]
141
+ output << " Last updated: #{repository[:pushed_at]}" if repository[:pushed_at]
142
+ output << " Created: #{repository[:created_at]}" if repository[:created_at]
143
+
144
+ # Links section
145
+ output << ""
146
+ output << "Links:"
147
+ output << " Homepage: #{repository[:homepage]}" if repository[:homepage]
148
+ output << " Repository: #{repository[:url]}" if repository[:url]
149
+
150
+ # Package Info section (repository stats)
151
+ output << ""
152
+ output << "Package Info:"
153
+ output << " Language: #{repository[:language]}" if repository[:language]
154
+ output << " License: #{repository[:license]}" if repository[:license]
155
+ output << " Stars: #{format_number(repository[:stars])}" if repository[:stars]
156
+ output << " Forks: #{format_number(repository[:forks])}" if repository[:forks]
157
+ output << " Open issues: #{format_number(repository[:open_issues])}" if repository[:open_issues]
158
+ output << " Fork: #{repository[:fork] ? 'Yes' : 'No'}" if !repository[:fork].nil?
159
+ output << " Archived: #{repository[:archived] ? 'Yes' : 'No'}" if !repository[:archived].nil?
160
+
161
+ if repository[:topics] && !repository[:topics].empty?
162
+ output << " Topics: #{repository[:topics].join(", ")}"
163
+ end
164
+
165
+ output.join("\n")
110
166
  end
111
167
 
112
168
  private
@@ -390,6 +390,23 @@ module Purl
390
390
  lookup_client.package_info(self)
391
391
  end
392
392
 
393
+ # Look up security advisories using the advisories.ecosyste.ms API
394
+ #
395
+ # @param user_agent [String] User agent string for API requests
396
+ # @param timeout [Integer] Request timeout in seconds
397
+ # @return [Array<Hash>] Array of advisory hashes, empty if none found
398
+ # @raise [AdvisoryError] if the lookup fails due to network or API errors
399
+ #
400
+ # @example
401
+ # purl = PackageURL.parse("pkg:npm/lodash@4.17.20")
402
+ # advisories = purl.advisories
403
+ # advisories.each { |adv| puts adv[:title] }
404
+ def advisories(user_agent: nil, timeout: 10)
405
+ require_relative "advisory"
406
+ advisory_client = Advisory.new(user_agent: user_agent, timeout: timeout)
407
+ advisory_client.lookup(self)
408
+ end
409
+
393
410
  private
394
411
 
395
412
  def validate_and_normalize_type(type)
data/lib/purl/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Purl
4
- VERSION = "1.5.2"
4
+ VERSION = "1.7.0"
5
5
  end