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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +141 -1
- data/exe/purl +58 -17
- data/lib/purl/advisory.rb +134 -0
- data/lib/purl/advisory_formatter.rb +160 -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 +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 29fc6866f5fbc457ae2e2f29842be73ce8b6a3c5025e6478253294a0ba001443
|
|
4
|
+
data.tar.gz: a345a99fddde1570eba0a38238848fd92c03b6e314835dc1bc18f98f589c11f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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>
|
|
68
|
-
purl [--json] validate <purl-string>
|
|
69
|
-
purl [--json] convert <registry-url>
|
|
70
|
-
purl [--json] url <purl-string>
|
|
71
|
-
purl [--json] generate [options]
|
|
72
|
-
purl [--json] info [type]
|
|
73
|
-
purl [--json] lookup <purl-string>
|
|
74
|
-
purl --
|
|
75
|
-
purl --
|
|
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
|
|
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
|
|
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("
|
|
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
|
-
#
|
|
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
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.
|
|
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
|