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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +221 -1
- data/exe/purl +128 -17
- data/lib/purl/advisory.rb +134 -0
- data/lib/purl/advisory_formatter.rb +160 -0
- data/lib/purl/download_url.rb +266 -0
- data/lib/purl/lookup.rb +54 -13
- data/lib/purl/lookup_formatter.rb +75 -19
- data/lib/purl/package_url.rb +17 -0
- data/lib/purl/version.rb +1 -1
- data/lib/purl.rb +15 -0
- metadata +5 -2
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
purl: purl_obj.to_s,
|
|
47
|
-
package: extract_package_info(package_data)
|
|
48
|
-
}
|
|
63
|
+
repo_data = make_request(repo_uri)
|
|
49
64
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
133
|
+
# Repository header - map to package-like format
|
|
134
|
+
output << "Package: #{repository[:name]} (repository)"
|
|
135
|
+
output << "#{repository[:description]}" if repository[:description]
|
|
108
136
|
|
|
109
|
-
|
|
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
|
data/lib/purl/package_url.rb
CHANGED
|
@@ -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