Skip to content

Commit 6501bf1

Browse files
committed
feat: add security advisory lookup command
closes #4
1 parent 6d81489 commit 6501bf1

File tree

8 files changed

+834
-16
lines changed

8 files changed

+834
-16
lines changed

README.md

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ This library features comprehensive error handling with namespaced error types,
1616

1717
## Features
1818

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
2020
- **Comprehensive PURL parsing and validation** with 37 package types (32 official + 5 additional ecosystems)
2121
- **Better error handling** with namespaced error classes and contextual information
2222
- **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
2325
- **Type-specific validation** for conan, cran, and swift packages
2426
- **Registry URL generation** for 20 package ecosystems (npm, gem, maven, pypi, etc.)
2527
- **Rails-style route patterns** for registry URL templates
@@ -70,6 +72,7 @@ purl url <purl-string> # Convert PURL to registry URL
7072
purl generate [options] # Generate PURL from components
7173
purl info [type] # Show information about PURL types
7274
purl lookup <purl-string> # Look up package information from ecosyste.ms
75+
purl advisories <purl-string> # Look up security advisories from advisories.ecosyste.ms
7376
```
7477

7578
### JSON Output
@@ -80,6 +83,7 @@ All commands support JSON output with the `--json` flag:
8083
purl --json parse "pkg:gem/[email protected]"
8184
purl --json info gem
8285
purl --json lookup "pkg:cargo/rand"
86+
purl --json advisories "pkg:npm/[email protected]"
8387
```
8488

8589
### Command Examples
@@ -228,6 +232,60 @@ $ purl --json lookup "pkg:cargo/[email protected]"
228232
}
229233
```
230234

235+
#### Look Up Security Advisories
236+
```bash
237+
$ purl advisories "pkg:npm/[email protected]"
238+
Security Advisories for pkg:npm/[email protected]
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/[email protected]"
263+
{
264+
"success": true,
265+
"purl": "pkg:npm/[email protected]",
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+
231289
### Generate Options
232290

233291
The `generate` command supports all PURL components:
@@ -465,6 +523,42 @@ puts info[:reverse_parsing] # => true
465523
puts info[:route_patterns] # => ["https://rubygems.org/gems/:name", ...]
466524
```
467525

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/[email protected]")
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+
468562
### Error Handling
469563

470564
```ruby

exe/purl

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ class PurlCLI
4444
info_command(args)
4545
when "lookup"
4646
lookup_command(args)
47+
when "advisories"
48+
advisories_command(args)
4749
when "--help", "-h", "help"
4850
puts usage
4951
exit 0
@@ -64,18 +66,19 @@ class PurlCLI
6466
purl - Parse, validate, convert and generate Package URLs (PURLs)
6567
6668
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
7679
7780
Global Options:
78-
--json Output results in JSON format
81+
--json Output results in JSON format
7982
8083
Examples:
8184
purl parse "pkg:gem/[email protected]"
@@ -86,6 +89,7 @@ class PurlCLI
8689
purl generate --type gem --name rails --version 7.0.0
8790
purl --json info gem
8891
purl lookup "pkg:cargo/rand"
92+
purl advisories "pkg:npm/[email protected]"
8993
USAGE
9094
end
9195

@@ -430,17 +434,17 @@ class PurlCLI
430434
end
431435

432436
purl_string = args[0]
433-
437+
434438
begin
435439
# Validate PURL first
436440
purl = Purl.parse(purl_string)
437-
441+
438442
# Use the library lookup method
439443
info = purl.lookup(user_agent: "purl-ruby-cli/#{Purl::VERSION}")
440-
444+
441445
# Use formatter to generate output
442446
formatter = Purl::LookupFormatter.new
443-
447+
444448
if @json_output
445449
result = formatter.format_json(info, purl)
446450
puts JSON.pretty_generate(result)
@@ -453,7 +457,7 @@ class PurlCLI
453457
exit 1
454458
end
455459
end
456-
460+
457461
rescue Purl::LookupError => e
458462
output_error("Lookup failed: #{e.message}")
459463
exit 1
@@ -466,6 +470,43 @@ class PurlCLI
466470
end
467471
end
468472

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
504+
rescue StandardError => e
505+
output_error("Advisory lookup failed: #{e.message}")
506+
exit 1
507+
end
508+
end
509+
469510
def output_error(message)
470511
if @json_output
471512
result = {

lib/purl.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
require_relative "purl/registry_url"
77
require_relative "purl/lookup"
88
require_relative "purl/lookup_formatter"
9+
require_relative "purl/advisory"
10+
require_relative "purl/advisory_formatter"
911

1012
# The main PURL (Package URL) module providing functionality to parse,
1113
# validate, and generate package URLs according to the PURL specification.

lib/purl/advisory.rb

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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/[email protected]")
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 commit comments

Comments
 (0)