purl 1.5.2 → 1.6.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: 02f6f258696c085108708cd45f9424357176d9efb2edecac7dac70e85bb2b98a
4
- data.tar.gz: 14e4f7a9ad77af4f4ea3d3a6a26041db433271515db710b94129f46af90318c3
3
+ metadata.gz: 29fc6866f5fbc457ae2e2f29842be73ce8b6a3c5025e6478253294a0ba001443
4
+ data.tar.gz: a345a99fddde1570eba0a38238848fd92c03b6e314835dc1bc18f98f589c11f8
5
5
  SHA512:
6
- metadata.gz: 623efd5ec3004f5a8ba188a067a894fd2a666ce2607de4c0243151342504446a551e081834e1692d5c52d24db8aaa37b2aa70c80a22969caf8235205661aa3cb
7
- data.tar.gz: 7fa445d38884d3df2537da649fcdcde0ea5a6c57b504d446f2e9c17b8544f160637ea8c8deb1da3879387954ef7384a36a85fbcfd51223f330955ba3ae46ab08
6
+ metadata.gz: a4196a9cee395fac223edccf0524c7eb297ada80411d658aa6c1ae6827248c48608b41fee66d616b9c5b3f8281d0099dbfaeb223f05b2cfac930bad9067b4c11
7
+ data.tar.gz: c0580ccb98f4dfb9516879049294c6feb553539d25d43a9f44fef35b9c60bb22857264e934148c11a2532e72149a43c919a0d00bdec4c3e12a965928e17ae140
data/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.0] - 2025-10-24
11
+
12
+ ### Added
13
+ - `advisories` command to CLI for looking up security advisories from advisories.ecosyste.ms API
14
+ - `advisories` instance method on `PackageURL` for programmatic advisory retrieval
15
+
16
+ ### Changed
17
+ - Updated to PURL specification v1.1
18
+
10
19
  ## [1.5.2] - 2025-08-06
11
20
 
12
21
  ### Fixed
data/README.md CHANGED
@@ -16,10 +16,12 @@ This library features comprehensive error handling with namespaced error types,
16
16
 
17
17
  ## Features
18
18
 
19
- - **Command-line interface** with parse, validate, convert, generate, and info commands plus JSON output
19
+ - **Command-line interface** with parse, validate, convert, generate, info, lookup, and advisories commands plus JSON output
20
20
  - **Comprehensive PURL parsing and validation** with 37 package types (32 official + 5 additional ecosystems)
21
21
  - **Better error handling** with namespaced error classes and contextual information
22
22
  - **Bidirectional registry URL conversion** - generate registry URLs from PURLs and parse PURLs from registry URLs
23
+ - **Security advisory lookup** - query security advisories from advisories.ecosyste.ms
24
+ - **Package information lookup** - query package metadata from ecosyste.ms
23
25
  - **Type-specific validation** for conan, cran, and swift packages
24
26
  - **Registry URL generation** for 20 package ecosystems (npm, gem, maven, pypi, etc.)
25
27
  - **Rails-style route patterns** for registry URL templates
@@ -69,6 +71,8 @@ purl convert <registry-url> # Convert registry URL to PURL
69
71
  purl url <purl-string> # Convert PURL to registry URL
70
72
  purl generate [options] # Generate PURL from components
71
73
  purl info [type] # Show information about PURL types
74
+ purl lookup <purl-string> # Look up package information from ecosyste.ms
75
+ purl advisories <purl-string> # Look up security advisories from advisories.ecosyste.ms
72
76
  ```
73
77
 
74
78
  ### JSON Output
@@ -78,6 +82,8 @@ All commands support JSON output with the `--json` flag:
78
82
  ```bash
79
83
  purl --json parse "pkg:gem/rails@7.0.0"
80
84
  purl --json info gem
85
+ purl --json lookup "pkg:cargo/rand"
86
+ purl --json advisories "pkg:npm/lodash@4.17.19"
81
87
  ```
82
88
 
83
89
  ### Command Examples
@@ -182,6 +188,104 @@ Total types: 37
182
188
  Registry supported: 20
183
189
  ```
184
190
 
191
+ #### Look Up Package Information
192
+ ```bash
193
+ $ purl lookup "pkg:cargo/rand"
194
+ Package: rand (cargo)
195
+ Description: Random number generators and other randomness functionality.
196
+ Homepage: https://rust-random.github.io/book
197
+ Repository: https://github.com/rust-random/rand
198
+ License: MIT OR Apache-2.0
199
+ Downloads: 145,678,901
200
+ Latest Version: 0.8.5
201
+ Published: 2023-01-13T17:47:01.870Z
202
+
203
+ $ purl --json lookup "pkg:cargo/rand@0.8.5"
204
+ {
205
+ "success": true,
206
+ "purl": "pkg:cargo/rand@0.8.5",
207
+ "package": {
208
+ "name": "rand",
209
+ "ecosystem": "cargo",
210
+ "description": "Random number generators and other randomness functionality.",
211
+ "homepage": "https://rust-random.github.io/book",
212
+ "repository_url": "https://github.com/rust-random/rand",
213
+ "registry_url": "https://crates.io/crates/rand",
214
+ "licenses": "MIT OR Apache-2.0",
215
+ "latest_version": "0.8.5",
216
+ "latest_version_published_at": "2023-01-13T17:47:01.870Z",
217
+ "versions_count": 89,
218
+ "maintainers": [
219
+ {
220
+ "login": "dhardy",
221
+ "name": "Diggory Hardy"
222
+ }
223
+ ]
224
+ },
225
+ "version": {
226
+ "number": "0.8.5",
227
+ "published_at": "2023-01-13T17:47:01.870Z",
228
+ "registry_url": "https://crates.io/crates/rand/0.8.5",
229
+ "downloads": 5678901,
230
+ "size": 102400
231
+ }
232
+ }
233
+ ```
234
+
235
+ #### Look Up Security Advisories
236
+ ```bash
237
+ $ purl advisories "pkg:npm/lodash@4.17.19"
238
+ Security Advisories for pkg:npm/lodash@4.17.19
239
+ ================================================================================
240
+
241
+ Advisory #1: Regular Expression Denial of Service (ReDoS) in lodash
242
+ Identifiers: GHSA-x5rq-j2xg-h7qm, CVE-2019-1010266
243
+ Severity: MODERATE
244
+
245
+ Description:
246
+ lodash prior to 4.7.11 is affected by: CWE-400: Uncontrolled Resource
247
+ Consumption. The impact is: Denial of service. The component is: Date
248
+ handler. The attack vector is: Attacker provides very long strings, which
249
+ the library attempts to match using a regular expression. The fixed version
250
+ is: 4.7.11.
251
+
252
+ Affected Packages:
253
+ Package: npm/lodash
254
+ Vulnerable: >= 4.7.0, < 4.17.11
255
+ Patched: 4.17.11
256
+
257
+ Source: github | Origin: UNSPECIFIED | Published: 2019-07-19T16:13:07.000Z
258
+ Advisory URL: https://github.com/advisories/GHSA-x5rq-j2xg-h7qm
259
+
260
+ Total advisories found: 3
261
+
262
+ $ purl --json advisories "pkg:npm/lodash@4.17.19"
263
+ {
264
+ "success": true,
265
+ "purl": "pkg:npm/lodash@4.17.19",
266
+ "advisories": [
267
+ {
268
+ "id": "MDE2OlNlY3VyaXR5QWR2aXNvcnlHSFNBLXg1cnEtajJ4Zy1oN3Ft",
269
+ "title": "Regular Expression Denial of Service (ReDoS) in lodash",
270
+ "description": "lodash prior to 4.7.11 is affected by...",
271
+ "severity": "MODERATE",
272
+ "url": "https://github.com/advisories/GHSA-x5rq-j2xg-h7qm",
273
+ "published_at": "2019-07-19T16:13:07.000Z",
274
+ "affected_packages": [
275
+ {
276
+ "ecosystem": "npm",
277
+ "name": "lodash",
278
+ "vulnerable_version_range": ">= 4.7.0, < 4.17.11",
279
+ "first_patched_version": "4.17.11"
280
+ }
281
+ ],
282
+ "identifiers": ["GHSA-x5rq-j2xg-h7qm", "CVE-2019-1010266"]
283
+ }
284
+ ],
285
+ "count": 3
286
+ }
287
+ ```
288
+
185
289
  ### Generate Options
186
290
 
187
291
  The `generate` command supports all PURL components:
@@ -419,6 +523,42 @@ puts info[:reverse_parsing] # => true
419
523
  puts info[:route_patterns] # => ["https://rubygems.org/gems/:name", ...]
420
524
  ```
421
525
 
526
+ ### Security Advisory Lookup
527
+
528
+ Look up security advisories for packages using the advisories.ecosyste.ms API:
529
+
530
+ ```ruby
531
+ # Look up advisories for a package
532
+ purl = Purl.parse("pkg:npm/lodash@4.17.19")
533
+ advisories = purl.advisories
534
+
535
+ # Display advisory information
536
+ advisories.each do |advisory|
537
+ puts "Title: #{advisory[:title]}"
538
+ puts "Severity: #{advisory[:severity]}"
539
+ puts "Description: #{advisory[:description]}"
540
+ puts "URL: #{advisory[:url]}"
541
+
542
+ # Show affected packages
543
+ advisory[:affected_packages].each do |pkg|
544
+ puts " Package: #{pkg[:ecosystem]}/#{pkg[:name]}"
545
+ puts " Vulnerable: #{pkg[:vulnerable_version_range]}"
546
+ puts " Patched: #{pkg[:first_patched_version]}" if pkg[:first_patched_version]
547
+ end
548
+
549
+ # Show identifiers (CVE, GHSA, etc.)
550
+ puts "Identifiers: #{advisory[:identifiers].join(', ')}"
551
+ puts
552
+ end
553
+
554
+ # Look up advisories for any version of a package
555
+ purl = Purl.parse("pkg:npm/lodash")
556
+ all_advisories = purl.advisories
557
+
558
+ # Use custom user agent and timeout
559
+ advisories = purl.advisories(user_agent: "my-app/1.0", timeout: 5)
560
+ ```
561
+
422
562
  ### Error Handling
423
563
 
424
564
  ```ruby
data/exe/purl CHANGED
@@ -44,6 +44,8 @@ class PurlCLI
44
44
  info_command(args)
45
45
  when "lookup"
46
46
  lookup_command(args)
47
+ when "advisories"
48
+ advisories_command(args)
47
49
  when "--help", "-h", "help"
48
50
  puts usage
49
51
  exit 0
@@ -64,18 +66,19 @@ class PurlCLI
64
66
  purl - Parse, validate, convert and generate Package URLs (PURLs)
65
67
 
66
68
  Usage:
67
- purl [--json] parse <purl-string> Parse and display PURL components
68
- purl [--json] validate <purl-string> Validate a PURL (exit code indicates success)
69
- purl [--json] convert <registry-url> Convert registry URL to PURL
70
- purl [--json] url <purl-string> Convert PURL to registry URL
71
- purl [--json] generate [options] Generate PURL from components
72
- purl [--json] info [type] Show information about PURL types
73
- purl [--json] lookup <purl-string> Look up package information from ecosyste.ms
74
- purl --version Show version
75
- purl --help Show this help
69
+ purl [--json] parse <purl-string> Parse and display PURL components
70
+ purl [--json] validate <purl-string> Validate a PURL (exit code indicates success)
71
+ purl [--json] convert <registry-url> Convert registry URL to PURL
72
+ purl [--json] url <purl-string> Convert PURL to registry URL
73
+ purl [--json] generate [options] Generate PURL from components
74
+ purl [--json] info [type] Show information about PURL types
75
+ purl [--json] lookup <purl-string> Look up package information from ecosyste.ms
76
+ purl [--json] advisories <purl-string> Look up security advisories from advisories.ecosyste.ms
77
+ purl --version Show version
78
+ purl --help Show this help
76
79
 
77
80
  Global Options:
78
- --json Output results in JSON format
81
+ --json Output results in JSON format
79
82
 
80
83
  Examples:
81
84
  purl parse "pkg:gem/rails@7.0.0"
@@ -86,6 +89,7 @@ class PurlCLI
86
89
  purl generate --type gem --name rails --version 7.0.0
87
90
  purl --json info gem
88
91
  purl lookup "pkg:cargo/rand"
92
+ purl advisories "pkg:npm/lodash@4.17.20"
89
93
  USAGE
90
94
  end
91
95
 
@@ -430,17 +434,17 @@ class PurlCLI
430
434
  end
431
435
 
432
436
  purl_string = args[0]
433
-
437
+
434
438
  begin
435
439
  # Validate PURL first
436
440
  purl = Purl.parse(purl_string)
437
-
441
+
438
442
  # Use the library lookup method
439
443
  info = purl.lookup(user_agent: "purl-ruby-cli/#{Purl::VERSION}")
440
-
444
+
441
445
  # Use formatter to generate output
442
446
  formatter = Purl::LookupFormatter.new
443
-
447
+
444
448
  if @json_output
445
449
  result = formatter.format_json(info, purl)
446
450
  puts JSON.pretty_generate(result)
@@ -453,15 +457,52 @@ class PurlCLI
453
457
  exit 1
454
458
  end
455
459
  end
456
-
460
+
461
+ rescue Purl::LookupError => e
462
+ output_error("Lookup failed: #{e.message}")
463
+ exit 1
457
464
  rescue Purl::Error => e
458
465
  output_error("Invalid PURL: #{e.message}")
459
466
  exit 1
460
- rescue Purl::LookupError => e
467
+ rescue StandardError => e
461
468
  output_error("Lookup failed: #{e.message}")
462
469
  exit 1
470
+ end
471
+ end
472
+
473
+ def advisories_command(args)
474
+ if args.empty?
475
+ output_error("PURL string required")
476
+ exit 1
477
+ end
478
+
479
+ purl_string = args[0]
480
+
481
+ begin
482
+ # Validate PURL first
483
+ purl = Purl.parse(purl_string)
484
+
485
+ # Use the library advisories method
486
+ advisories = purl.advisories(user_agent: "purl-ruby-cli/#{Purl::VERSION}")
487
+
488
+ # Use formatter to generate output
489
+ formatter = Purl::AdvisoryFormatter.new
490
+
491
+ if @json_output
492
+ result = formatter.format_json(advisories, purl)
493
+ puts JSON.pretty_generate(result)
494
+ else
495
+ puts formatter.format_text(advisories, purl)
496
+ end
497
+
498
+ rescue Purl::AdvisoryError => e
499
+ output_error("Advisory lookup failed: #{e.message}")
500
+ exit 1
501
+ rescue Purl::Error => e
502
+ output_error("Invalid PURL: #{e.message}")
503
+ exit 1
463
504
  rescue StandardError => e
464
- output_error("Lookup failed: #{e.message}")
505
+ output_error("Advisory lookup failed: #{e.message}")
465
506
  exit 1
466
507
  end
467
508
  end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "timeout"
7
+
8
+ module Purl
9
+ # Provides advisory lookup functionality for packages using the advisories.ecosyste.ms API
10
+ class Advisory
11
+ ADVISORIES_API_BASE = "https://advisories.ecosyste.ms/api/v1"
12
+
13
+ # Initialize a new Advisory instance
14
+ #
15
+ # @param user_agent [String] User agent string for API requests
16
+ # @param timeout [Integer] Request timeout in seconds
17
+ def initialize(user_agent: nil, timeout: 10)
18
+ @user_agent = user_agent || "purl-ruby/#{Purl::VERSION}"
19
+ @timeout = timeout
20
+ end
21
+
22
+ # Look up security advisories for a given PURL
23
+ #
24
+ # @param purl [String, PackageURL] PURL string or PackageURL object
25
+ # @return [Array<Hash>, nil] Array of advisory hashes or nil if none found
26
+ # @raise [AdvisoryError] if the lookup fails due to network or API errors
27
+ #
28
+ # @example
29
+ # advisory = Purl::Advisory.new
30
+ # advisories = advisory.lookup("pkg:npm/lodash@4.17.20")
31
+ # advisories.each { |adv| puts adv[:title] }
32
+ def lookup(purl)
33
+ purl_obj = purl.is_a?(PackageURL) ? purl : PackageURL.parse(purl.to_s)
34
+
35
+ # Query advisories API
36
+ uri = URI("#{ADVISORIES_API_BASE}/advisories/lookup")
37
+ uri.query = URI.encode_www_form({ purl: purl_obj.to_s })
38
+
39
+ response_data = make_request(uri)
40
+
41
+ if response_data.is_a?(Array) && response_data.length > 0
42
+ advisories = response_data.map { |advisory_data| extract_advisory_info(advisory_data) }
43
+
44
+ # Filter by version if specified
45
+ if purl_obj.version
46
+ advisories = filter_by_version(advisories, purl_obj.version)
47
+ end
48
+
49
+ return advisories
50
+ end
51
+
52
+ []
53
+ end
54
+
55
+ private
56
+
57
+ def make_request(uri)
58
+ http = Net::HTTP.new(uri.host, uri.port)
59
+ http.use_ssl = true
60
+ http.read_timeout = @timeout
61
+ http.open_timeout = @timeout
62
+
63
+ request = Net::HTTP::Get.new(uri)
64
+ request["User-Agent"] = @user_agent
65
+
66
+ response = http.request(request)
67
+
68
+ case response.code.to_i
69
+ when 200
70
+ JSON.parse(response.body)
71
+ when 404
72
+ []
73
+ else
74
+ raise AdvisoryError, "API request failed with status #{response.code}"
75
+ end
76
+ rescue JSON::ParserError => e
77
+ raise AdvisoryError, "Failed to parse API response: #{e.message}"
78
+ rescue Timeout::Error, Net::OpenTimeout, Net::ReadTimeout => e
79
+ raise AdvisoryError, "Request timeout: #{e.message}"
80
+ rescue StandardError => e
81
+ raise AdvisoryError, "Advisory lookup failed: #{e.message}"
82
+ end
83
+
84
+ def extract_advisory_info(advisory_data)
85
+ {
86
+ id: advisory_data["uuid"],
87
+ title: advisory_data["title"],
88
+ description: advisory_data["description"],
89
+ severity: advisory_data["severity"],
90
+ cvss_score: advisory_data["cvss_score"],
91
+ cvss_vector: advisory_data["cvss_vector"],
92
+ url: advisory_data["url"],
93
+ repository_url: advisory_data["repository_url"],
94
+ published_at: advisory_data["published_at"],
95
+ updated_at: advisory_data["updated_at"],
96
+ withdrawn_at: advisory_data["withdrawn_at"],
97
+ source_kind: advisory_data["source_kind"],
98
+ origin: advisory_data["origin"],
99
+ classification: advisory_data["classification"],
100
+ affected_packages: extract_affected_packages(advisory_data["packages"]),
101
+ references: advisory_data["references"],
102
+ identifiers: advisory_data["identifiers"]
103
+ }.compact
104
+ end
105
+
106
+ def extract_affected_packages(packages)
107
+ return [] unless packages && packages.is_a?(Array)
108
+
109
+ packages.map do |pkg|
110
+ version_info = pkg["versions"]&.first || {}
111
+ {
112
+ ecosystem: pkg["ecosystem"],
113
+ name: pkg["package_name"],
114
+ purl: pkg["purl"],
115
+ vulnerable_version_range: version_info["vulnerable_version_range"],
116
+ first_patched_version: version_info["first_patched_version"]
117
+ }.compact
118
+ end
119
+ end
120
+
121
+ def filter_by_version(advisories, version)
122
+ # For now, return all advisories if version is specified
123
+ # More sophisticated version range matching could be added later
124
+ advisories
125
+ end
126
+ end
127
+
128
+ # Error raised when advisory lookup fails
129
+ class AdvisoryError < Error
130
+ def initialize(message)
131
+ super(message)
132
+ end
133
+ end
134
+ end
@@ -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
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.6.0"
5
5
  end
data/lib/purl.rb CHANGED
@@ -6,6 +6,8 @@ require_relative "purl/package_url"
6
6
  require_relative "purl/registry_url"
7
7
  require_relative "purl/lookup"
8
8
  require_relative "purl/lookup_formatter"
9
+ require_relative "purl/advisory"
10
+ require_relative "purl/advisory_formatter"
9
11
 
10
12
  # The main PURL (Package URL) module providing functionality to parse,
11
13
  # validate, and generate package URLs according to the PURL specification.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: purl
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.2
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -43,6 +43,8 @@ files:
43
43
  - SECURITY.md
44
44
  - exe/purl
45
45
  - lib/purl.rb
46
+ - lib/purl/advisory.rb
47
+ - lib/purl/advisory_formatter.rb
46
48
  - lib/purl/errors.rb
47
49
  - lib/purl/lookup.rb
48
50
  - lib/purl/lookup_formatter.rb