purl 1.5.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +221 -1
- data/exe/purl +128 -17
- data/lib/purl/advisory.rb +134 -0
- data/lib/purl/advisory_formatter.rb +160 -0
- data/lib/purl/download_url.rb +266 -0
- data/lib/purl/lookup.rb +54 -13
- data/lib/purl/lookup_formatter.rb +75 -19
- data/lib/purl/package_url.rb +17 -0
- data/lib/purl/version.rb +1 -1
- data/lib/purl.rb +15 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 02a4895e51ce7c9ace65a53d97fd8d4c5d288d7137daa71c9e8b8a778595e6cb
|
|
4
|
+
data.tar.gz: 55d348a442e23a0ffddc2b236bc1be22c9cf29d8eac0ce3ff289ce637e1ba6f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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>
|
|
68
|
-
purl [--json] validate <purl-string>
|
|
69
|
-
purl [--json] convert <registry-url>
|
|
70
|
-
purl [--json] url <purl-string>
|
|
71
|
-
purl [--json]
|
|
72
|
-
purl [--json]
|
|
73
|
-
purl [--json]
|
|
74
|
-
purl --
|
|
75
|
-
purl --
|
|
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
|
|
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
|
|
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("
|
|
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
|