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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02f6f258696c085108708cd45f9424357176d9efb2edecac7dac70e85bb2b98a
4
- data.tar.gz: 14e4f7a9ad77af4f4ea3d3a6a26041db433271515db710b94129f46af90318c3
3
+ metadata.gz: 02a4895e51ce7c9ace65a53d97fd8d4c5d288d7137daa71c9e8b8a778595e6cb
4
+ data.tar.gz: 55d348a442e23a0ffddc2b236bc1be22c9cf29d8eac0ce3ff289ce637e1ba6f5
5
5
  SHA512:
6
- metadata.gz: 623efd5ec3004f5a8ba188a067a894fd2a666ce2607de4c0243151342504446a551e081834e1692d5c52d24db8aaa37b2aa70c80a22969caf8235205661aa3cb
7
- data.tar.gz: 7fa445d38884d3df2537da649fcdcde0ea5a6c57b504d446f2e9c17b8544f160637ea8c8deb1da3879387954ef7384a36a85fbcfd51223f330955ba3ae46ab08
6
+ metadata.gz: 4f8ff4ff13c1184c4b1adf6950fc1d04acedb8ce2cbbf4d62f2430455567e98f8b153b5493732919e1921a55132a2f3f51b61157b6eba85e207e6675d8675771
7
+ data.tar.gz: 9f203b73ef705fe70a27de5aae3b85558bd34204a2362a7429c441dc7f66d4a5ae403741fcb36471ab33e972868e4d31e6121a5eeaf23fb88832e3d38fec1e9c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.7.0] - 2026-01-02
11
+
12
+ ### Added
13
+ - Download URL generation for 18 package ecosystems
14
+ - `download_url` instance method on `PackageURL` for getting artifact download URLs
15
+ - `supports_download_url?` method to check if download URL generation is supported
16
+ - `Purl.download_supported_types` to list all types with download URL support
17
+ - `download` command in CLI for getting download URLs from PURLs
18
+ - Supported types: bioconductor, bitbucket, cargo, clojars, cran, elm, gem, github, gitlab, golang, hackage, hex, luarocks, maven, npm, nuget, pub, swift
19
+ - Support for `repository_url` qualifier to use custom registries for downloads
20
+ - Support for custom base URLs via parameter override
21
+
22
+ ## [1.6.0] - 2025-10-24
23
+
24
+ ### Added
25
+ - `advisories` command to CLI for looking up security advisories from advisories.ecosyste.ms API
26
+ - `advisories` instance method on `PackageURL` for programmatic advisory retrieval
27
+
28
+ ### Changed
29
+ - Updated to PURL specification v1.1
30
+
10
31
  ## [1.5.2] - 2025-08-06
11
32
 
12
33
  ### Fixed
data/README.md CHANGED
@@ -16,10 +16,13 @@ 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, download, 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
+ - **Download URL generation** for 18 package ecosystems (gem, npm, cargo, maven, etc.)
24
+ - **Security advisory lookup** - query security advisories from advisories.ecosyste.ms
25
+ - **Package information lookup** - query package metadata from ecosyste.ms
23
26
  - **Type-specific validation** for conan, cran, and swift packages
24
27
  - **Registry URL generation** for 20 package ecosystems (npm, gem, maven, pypi, etc.)
25
28
  - **Rails-style route patterns** for registry URL templates
@@ -67,8 +70,11 @@ purl parse <purl-string> # Parse and display PURL components
67
70
  purl validate <purl-string> # Validate a PURL (exit code indicates success)
68
71
  purl convert <registry-url> # Convert registry URL to PURL
69
72
  purl url <purl-string> # Convert PURL to registry URL
73
+ purl download <purl-string> # Get download URL for package version
70
74
  purl generate [options] # Generate PURL from components
71
75
  purl info [type] # Show information about PURL types
76
+ purl lookup <purl-string> # Look up package information from ecosyste.ms
77
+ purl advisories <purl-string> # Look up security advisories from advisories.ecosyste.ms
72
78
  ```
73
79
 
74
80
  ### JSON Output
@@ -78,6 +84,8 @@ All commands support JSON output with the `--json` flag:
78
84
  ```bash
79
85
  purl --json parse "pkg:gem/rails@7.0.0"
80
86
  purl --json info gem
87
+ purl --json lookup "pkg:cargo/rand"
88
+ purl --json advisories "pkg:npm/lodash@4.17.19"
81
89
  ```
82
90
 
83
91
  ### Command Examples
@@ -144,6 +152,26 @@ $ purl --json url "pkg:gem/rails@7.0.0"
144
152
  }
145
153
  ```
146
154
 
155
+ #### Get Download URL
156
+ ```bash
157
+ $ purl download "pkg:gem/rails@7.0.0"
158
+ https://rubygems.org/downloads/rails-7.0.0.gem
159
+
160
+ $ purl download "pkg:npm/@babel/core@7.20.0"
161
+ https://registry.npmjs.org/@babel/core/-/core-7.20.0.tgz
162
+
163
+ $ purl download "pkg:cargo/serde@1.0.152"
164
+ https://static.crates.io/crates/serde/serde-1.0.152.crate
165
+
166
+ $ purl --json download "pkg:maven/org.apache.commons/commons-lang3@3.12.0"
167
+ {
168
+ "success": true,
169
+ "purl": "pkg:maven/org.apache.commons/commons-lang3@3.12.0",
170
+ "download_url": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar",
171
+ "type": "maven"
172
+ }
173
+ ```
174
+
147
175
  #### Generate a PURL
148
176
  ```bash
149
177
  $ purl generate --type gem --name rails --version 7.0.0
@@ -182,6 +210,104 @@ Total types: 37
182
210
  Registry supported: 20
183
211
  ```
184
212
 
213
+ #### Look Up Package Information
214
+ ```bash
215
+ $ purl lookup "pkg:cargo/rand"
216
+ Package: rand (cargo)
217
+ Description: Random number generators and other randomness functionality.
218
+ Homepage: https://rust-random.github.io/book
219
+ Repository: https://github.com/rust-random/rand
220
+ License: MIT OR Apache-2.0
221
+ Downloads: 145,678,901
222
+ Latest Version: 0.8.5
223
+ Published: 2023-01-13T17:47:01.870Z
224
+
225
+ $ purl --json lookup "pkg:cargo/rand@0.8.5"
226
+ {
227
+ "success": true,
228
+ "purl": "pkg:cargo/rand@0.8.5",
229
+ "package": {
230
+ "name": "rand",
231
+ "ecosystem": "cargo",
232
+ "description": "Random number generators and other randomness functionality.",
233
+ "homepage": "https://rust-random.github.io/book",
234
+ "repository_url": "https://github.com/rust-random/rand",
235
+ "registry_url": "https://crates.io/crates/rand",
236
+ "licenses": "MIT OR Apache-2.0",
237
+ "latest_version": "0.8.5",
238
+ "latest_version_published_at": "2023-01-13T17:47:01.870Z",
239
+ "versions_count": 89,
240
+ "maintainers": [
241
+ {
242
+ "login": "dhardy",
243
+ "name": "Diggory Hardy"
244
+ }
245
+ ]
246
+ },
247
+ "version": {
248
+ "number": "0.8.5",
249
+ "published_at": "2023-01-13T17:47:01.870Z",
250
+ "registry_url": "https://crates.io/crates/rand/0.8.5",
251
+ "downloads": 5678901,
252
+ "size": 102400
253
+ }
254
+ }
255
+ ```
256
+
257
+ #### Look Up Security Advisories
258
+ ```bash
259
+ $ purl advisories "pkg:npm/lodash@4.17.19"
260
+ Security Advisories for pkg:npm/lodash@4.17.19
261
+ ================================================================================
262
+
263
+ Advisory #1: Regular Expression Denial of Service (ReDoS) in lodash
264
+ Identifiers: GHSA-x5rq-j2xg-h7qm, CVE-2019-1010266
265
+ Severity: MODERATE
266
+
267
+ Description:
268
+ lodash prior to 4.7.11 is affected by: CWE-400: Uncontrolled Resource
269
+ Consumption. The impact is: Denial of service. The component is: Date
270
+ handler. The attack vector is: Attacker provides very long strings, which
271
+ the library attempts to match using a regular expression. The fixed version
272
+ is: 4.7.11.
273
+
274
+ Affected Packages:
275
+ Package: npm/lodash
276
+ Vulnerable: >= 4.7.0, < 4.17.11
277
+ Patched: 4.17.11
278
+
279
+ Source: github | Origin: UNSPECIFIED | Published: 2019-07-19T16:13:07.000Z
280
+ Advisory URL: https://github.com/advisories/GHSA-x5rq-j2xg-h7qm
281
+
282
+ Total advisories found: 3
283
+
284
+ $ purl --json advisories "pkg:npm/lodash@4.17.19"
285
+ {
286
+ "success": true,
287
+ "purl": "pkg:npm/lodash@4.17.19",
288
+ "advisories": [
289
+ {
290
+ "id": "MDE2OlNlY3VyaXR5QWR2aXNvcnlHSFNBLXg1cnEtajJ4Zy1oN3Ft",
291
+ "title": "Regular Expression Denial of Service (ReDoS) in lodash",
292
+ "description": "lodash prior to 4.7.11 is affected by...",
293
+ "severity": "MODERATE",
294
+ "url": "https://github.com/advisories/GHSA-x5rq-j2xg-h7qm",
295
+ "published_at": "2019-07-19T16:13:07.000Z",
296
+ "affected_packages": [
297
+ {
298
+ "ecosystem": "npm",
299
+ "name": "lodash",
300
+ "vulnerable_version_range": ">= 4.7.0, < 4.17.11",
301
+ "first_patched_version": "4.17.11"
302
+ }
303
+ ],
304
+ "identifiers": ["GHSA-x5rq-j2xg-h7qm", "CVE-2019-1010266"]
305
+ }
306
+ ],
307
+ "count": 3
308
+ }
309
+ ```
310
+
185
311
  ### Generate Options
186
312
 
187
313
  The `generate` command supports all PURL components:
@@ -304,6 +430,42 @@ purl = Purl.parse("pkg:npm/@babel/core@7.0.0")
304
430
  puts purl.registry_url # => "https://www.npmjs.com/package/@babel/core"
305
431
  ```
306
432
 
433
+ ### Download URL Generation
434
+
435
+ Generate direct download URLs for package artifacts:
436
+
437
+ ```ruby
438
+ # Generate download URLs from PURLs
439
+ purl = Purl.parse("pkg:gem/rails@7.0.0")
440
+ puts purl.download_url # => "https://rubygems.org/downloads/rails-7.0.0.gem"
441
+
442
+ # Check if download URL generation is supported
443
+ puts purl.supports_download_url? # => true
444
+
445
+ # NPM with scoped packages
446
+ purl = Purl.parse("pkg:npm/@babel/core@7.20.0")
447
+ puts purl.download_url # => "https://registry.npmjs.org/@babel/core/-/core-7.20.0.tgz"
448
+
449
+ # Maven packages
450
+ purl = Purl.parse("pkg:maven/org.apache.commons/commons-lang3@3.12.0")
451
+ puts purl.download_url # => "https://repo.maven.apache.org/maven2/org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar"
452
+
453
+ # Custom registry via repository_url qualifier
454
+ purl = Purl.parse("pkg:npm/lodash@4.17.21?repository_url=https://npm.mycompany.com")
455
+ puts purl.download_url # => "https://npm.mycompany.com/lodash/-/lodash-4.17.21.tgz"
456
+
457
+ # Custom registry via parameter
458
+ purl = Purl.parse("pkg:gem/rails@7.0.0")
459
+ puts purl.download_url(base_url: "https://gems.internal.com/downloads")
460
+ # => "https://gems.internal.com/downloads/rails-7.0.0.gem"
461
+
462
+ # Get list of supported types
463
+ puts Purl.download_supported_types
464
+ # => ["bioconductor", "bitbucket", "cargo", "clojars", "cran", "elm", "gem",
465
+ # "github", "gitlab", "golang", "hackage", "hex", "luarocks", "maven",
466
+ # "npm", "nuget", "pub", "swift"]
467
+ ```
468
+
307
469
  ### Reverse Parsing: Registry URLs to PURLs
308
470
 
309
471
  ```ruby
@@ -397,6 +559,7 @@ puts Purl.known_types.include?("gem") # => true
397
559
  puts Purl.known_type?("gem") # => true
398
560
  puts Purl.registry_supported_types # => ["cargo", "gem", "maven", "npm", ...]
399
561
  puts Purl.reverse_parsing_supported_types # => ["bioconductor", "cargo", "clojars", ...]
562
+ puts Purl.download_supported_types # => ["cargo", "gem", "maven", "npm", ...]
400
563
 
401
564
  # Get default registry for a type
402
565
  puts Purl.default_registry("gem") # => "https://rubygems.org"
@@ -416,9 +579,46 @@ puts info[:default_registry] # => "https://rubygems.org"
416
579
  puts info[:examples] # => ["pkg:gem/rails@7.0.4", ...]
417
580
  puts info[:registry_url_generation] # => true
418
581
  puts info[:reverse_parsing] # => true
582
+ puts info[:download_url_generation] # => true
419
583
  puts info[:route_patterns] # => ["https://rubygems.org/gems/:name", ...]
420
584
  ```
421
585
 
586
+ ### Security Advisory Lookup
587
+
588
+ Look up security advisories for packages using the advisories.ecosyste.ms API:
589
+
590
+ ```ruby
591
+ # Look up advisories for a package
592
+ purl = Purl.parse("pkg:npm/lodash@4.17.19")
593
+ advisories = purl.advisories
594
+
595
+ # Display advisory information
596
+ advisories.each do |advisory|
597
+ puts "Title: #{advisory[:title]}"
598
+ puts "Severity: #{advisory[:severity]}"
599
+ puts "Description: #{advisory[:description]}"
600
+ puts "URL: #{advisory[:url]}"
601
+
602
+ # Show affected packages
603
+ advisory[:affected_packages].each do |pkg|
604
+ puts " Package: #{pkg[:ecosystem]}/#{pkg[:name]}"
605
+ puts " Vulnerable: #{pkg[:vulnerable_version_range]}"
606
+ puts " Patched: #{pkg[:first_patched_version]}" if pkg[:first_patched_version]
607
+ end
608
+
609
+ # Show identifiers (CVE, GHSA, etc.)
610
+ puts "Identifiers: #{advisory[:identifiers].join(', ')}"
611
+ puts
612
+ end
613
+
614
+ # Look up advisories for any version of a package
615
+ purl = Purl.parse("pkg:npm/lodash")
616
+ all_advisories = purl.advisories
617
+
618
+ # Use custom user agent and timeout
619
+ advisories = purl.advisories(user_agent: "my-app/1.0", timeout: 5)
620
+ ```
621
+
422
622
  ### Error Handling
423
623
 
424
624
  ```ruby
@@ -470,6 +670,26 @@ The library supports 37 package types (32 official + 5 additional ecosystems):
470
670
  **Reverse Parsing (20 types):**
471
671
  - `bioconductor`, `cargo`, `clojars`, `cocoapods`, `composer`, `conda`, `cpan`, `deno`, `elm`, `gem`, `golang`, `hackage`, `hex`, `homebrew`, `maven`, `npm`, `nuget`, `pub`, `pypi`, `swift`
472
672
 
673
+ **Download URL Generation (18 types):**
674
+ - `bioconductor` - bioconductor.org/packages/release/bioc/src/contrib
675
+ - `bitbucket` - bitbucket.org archive downloads
676
+ - `cargo` - static.crates.io/crates
677
+ - `clojars` - repo.clojars.org (Maven-style)
678
+ - `cran` - cran.r-project.org/src/contrib
679
+ - `elm` - GitHub archive downloads
680
+ - `gem` - rubygems.org/downloads
681
+ - `github` - GitHub archive downloads
682
+ - `gitlab` - GitLab archive downloads
683
+ - `golang` - proxy.golang.org
684
+ - `hackage` - hackage.haskell.org/package
685
+ - `hex` - repo.hex.pm/tarballs
686
+ - `luarocks` - luarocks.org/manifests
687
+ - `maven` - repo.maven.apache.org/maven2
688
+ - `npm` - registry.npmjs.org
689
+ - `nuget` - api.nuget.org/v3-flatcontainer
690
+ - `pub` - pub.dev/packages
691
+ - `swift` - GitHub/GitLab/Bitbucket (derived from namespace)
692
+
473
693
  **All 37 Supported Types:**
474
694
  `alpm`, `apk`, `bioconductor`, `bitbucket`, `bitnami`, `cargo`, `clojars`, `cocoapods`, `composer`, `conan`, `conda`, `cpan`, `cran`, `deb`, `deno`, `docker`, `elm`, `gem`, `generic`, `github`, `golang`, `hackage`, `hex`, `homebrew`, `huggingface`, `luarocks`, `maven`, `mlflow`, `npm`, `nuget`, `oci`, `pub`, `pypi`, `qpkg`, `rpm`, `swid`, `swift`
475
695
 
data/exe/purl CHANGED
@@ -40,10 +40,14 @@ class PurlCLI
40
40
  generate_command(args)
41
41
  when "url"
42
42
  url_command(args)
43
+ when "download"
44
+ download_command(args)
43
45
  when "info"
44
46
  info_command(args)
45
47
  when "lookup"
46
48
  lookup_command(args)
49
+ when "advisories"
50
+ advisories_command(args)
47
51
  when "--help", "-h", "help"
48
52
  puts usage
49
53
  exit 0
@@ -64,18 +68,20 @@ class PurlCLI
64
68
  purl - Parse, validate, convert and generate Package URLs (PURLs)
65
69
 
66
70
  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
71
+ purl [--json] parse <purl-string> Parse and display PURL components
72
+ purl [--json] validate <purl-string> Validate a PURL (exit code indicates success)
73
+ purl [--json] convert <registry-url> Convert registry URL to PURL
74
+ purl [--json] url <purl-string> Convert PURL to registry URL
75
+ purl [--json] download <purl-string> Get download URL for package version
76
+ purl [--json] generate [options] Generate PURL from components
77
+ purl [--json] info [type] Show information about PURL types
78
+ purl [--json] lookup <purl-string> Look up package information from ecosyste.ms
79
+ purl [--json] advisories <purl-string> Look up security advisories from advisories.ecosyste.ms
80
+ purl --version Show version
81
+ purl --help Show this help
76
82
 
77
83
  Global Options:
78
- --json Output results in JSON format
84
+ --json Output results in JSON format
79
85
 
80
86
  Examples:
81
87
  purl parse "pkg:gem/rails@7.0.0"
@@ -86,6 +92,7 @@ class PurlCLI
86
92
  purl generate --type gem --name rails --version 7.0.0
87
93
  purl --json info gem
88
94
  purl lookup "pkg:cargo/rand"
95
+ purl advisories "pkg:npm/lodash@4.17.20"
89
96
  USAGE
90
97
  end
91
98
 
@@ -273,6 +280,73 @@ class PurlCLI
273
280
  end
274
281
  end
275
282
 
283
+ def download_command(args)
284
+ if args.empty?
285
+ output_error("PURL string required")
286
+ exit 1
287
+ end
288
+
289
+ purl_string = args[0]
290
+
291
+ begin
292
+ purl = Purl.parse(purl_string)
293
+
294
+ unless purl.supports_download_url?
295
+ if @json_output
296
+ result = {
297
+ success: false,
298
+ purl: purl_string,
299
+ error: "Download URL generation not supported for type '#{purl.type}'",
300
+ supported_types: Purl.download_supported_types
301
+ }
302
+ puts JSON.pretty_generate(result)
303
+ else
304
+ puts "Error: Download URL generation not supported for type '#{purl.type}'"
305
+ puts "Supported types: #{Purl.download_supported_types.join(', ')}"
306
+ end
307
+ exit 1
308
+ end
309
+
310
+ download_url = purl.download_url
311
+
312
+ if @json_output
313
+ result = {
314
+ success: true,
315
+ purl: purl.to_s,
316
+ download_url: download_url,
317
+ type: purl.type
318
+ }
319
+ puts JSON.pretty_generate(result)
320
+ else
321
+ puts download_url
322
+ end
323
+ rescue Purl::MissingVersionError => e
324
+ if @json_output
325
+ result = {
326
+ success: false,
327
+ purl: purl_string,
328
+ error: "Version required for download URL"
329
+ }
330
+ puts JSON.pretty_generate(result)
331
+ else
332
+ puts "Error: Version required for download URL"
333
+ end
334
+ exit 1
335
+ rescue Purl::Error => e
336
+ if @json_output
337
+ result = {
338
+ success: false,
339
+ purl: purl_string,
340
+ error: e.message
341
+ }
342
+ puts JSON.pretty_generate(result)
343
+ else
344
+ puts "Error: #{e.message}"
345
+ end
346
+ exit 1
347
+ end
348
+ end
349
+
276
350
  def generate_command(args)
277
351
  options = {}
278
352
  OptionParser.new do |opts|
@@ -430,17 +504,17 @@ class PurlCLI
430
504
  end
431
505
 
432
506
  purl_string = args[0]
433
-
507
+
434
508
  begin
435
509
  # Validate PURL first
436
510
  purl = Purl.parse(purl_string)
437
-
511
+
438
512
  # Use the library lookup method
439
513
  info = purl.lookup(user_agent: "purl-ruby-cli/#{Purl::VERSION}")
440
-
514
+
441
515
  # Use formatter to generate output
442
516
  formatter = Purl::LookupFormatter.new
443
-
517
+
444
518
  if @json_output
445
519
  result = formatter.format_json(info, purl)
446
520
  puts JSON.pretty_generate(result)
@@ -453,15 +527,52 @@ class PurlCLI
453
527
  exit 1
454
528
  end
455
529
  end
456
-
530
+
531
+ rescue Purl::LookupError => e
532
+ output_error("Lookup failed: #{e.message}")
533
+ exit 1
457
534
  rescue Purl::Error => e
458
535
  output_error("Invalid PURL: #{e.message}")
459
536
  exit 1
460
- rescue Purl::LookupError => e
537
+ rescue StandardError => e
461
538
  output_error("Lookup failed: #{e.message}")
462
539
  exit 1
540
+ end
541
+ end
542
+
543
+ def advisories_command(args)
544
+ if args.empty?
545
+ output_error("PURL string required")
546
+ exit 1
547
+ end
548
+
549
+ purl_string = args[0]
550
+
551
+ begin
552
+ # Validate PURL first
553
+ purl = Purl.parse(purl_string)
554
+
555
+ # Use the library advisories method
556
+ advisories = purl.advisories(user_agent: "purl-ruby-cli/#{Purl::VERSION}")
557
+
558
+ # Use formatter to generate output
559
+ formatter = Purl::AdvisoryFormatter.new
560
+
561
+ if @json_output
562
+ result = formatter.format_json(advisories, purl)
563
+ puts JSON.pretty_generate(result)
564
+ else
565
+ puts formatter.format_text(advisories, purl)
566
+ end
567
+
568
+ rescue Purl::AdvisoryError => e
569
+ output_error("Advisory lookup failed: #{e.message}")
570
+ exit 1
571
+ rescue Purl::Error => e
572
+ output_error("Invalid PURL: #{e.message}")
573
+ exit 1
463
574
  rescue StandardError => e
464
- output_error("Lookup failed: #{e.message}")
575
+ output_error("Advisory lookup failed: #{e.message}")
465
576
  exit 1
466
577
  end
467
578
  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