Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ gem 'sidekiq'
gem 'sidekiq-unique-jobs'
gem 'appsignal'
gem 'dalli'
gem 'rubyzip'

group :development, :test do
gem 'dotenv-rails'
Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ GEM
actionpack (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2)
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
Expand Down Expand Up @@ -348,6 +349,7 @@ DEPENDENCIES
redis
rswag-api
rswag-ui
rubyzip
sassc-rails
secure_headers
semantic
Expand Down Expand Up @@ -477,6 +479,7 @@ CHECKSUMS
rswag-api (2.17.0) sha256=728b336b65168ab8ab6024b0e5d267b485c22ccdeb9dfbfb6ec3bac423545a13
rswag-ui (2.17.0) sha256=5f707b9b5e8171ddf9f519f6e401e79e419bd1d07387508603e76124f2443212
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c
sassc (2.4.0) sha256=4c60a2b0a3b36685c83b80d5789401c2f678c1652e3288315a1551d811d9f83e
sassc-rails (2.1.2) sha256=5f4fdf3881fc9bdc8e856ffbd9850d70a2878866feae8114aa45996179952db5
sawyer (0.9.3) sha256=0d0f19298408047037638639fe62f4794483fb04320269169bd41af2bdcf5e41
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,25 @@ This project is part of [Ecosyste.ms](https://ecosyste.ms): Tools and open datas

Documentation for the REST API is available here: [https://advisories.ecosyste.ms/docs](https://advisories.ecosyste.ms/docs)

The default rate limit for the API is 5000/req per hour based on your IP address, get in contact if you need to to increase your rate limit.
The default rate limit for the API is 5000/req per hour based on your IP address, get in contact if you need to increase your rate limit.

## OSV API

An [OSV-compatible API](https://ossf.github.io/osv-schema/) is available at `/v1` for interoperability with other vulnerability databases and tooling.

Endpoints:

- `POST /v1/query` - Query vulnerabilities by package/ecosystem or PURL
- `POST /v1/querybatch` - Batch query up to 1000 packages
- `GET /v1/vulns/:id` - Get vulnerability by ID (UUID or CVE)

Example:

```bash
curl -X POST https://advisories.ecosyste.ms/v1/query \
-H "Content-Type: application/json" \
-d '{"package":{"name":"lodash","ecosystem":"npm"}}'
```

## Development

Expand Down
5 changes: 4 additions & 1 deletion app/controllers/advisories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ def index
@sources = Source.joins(:advisories).group(:id).order(:name).count.to_a.map { |id, count| [Source.find(id), count] }
scope = scope.source_kind(params[:source]) if params[:source].present?

@severities = scope.group(:severity).count.to_a.sort_by{|a| a[1]}.reverse
@severities = scope.group(:severity).count.reject { |k, _| k.nil? }.to_a.sort_by{|a| a[1]}.reverse
scope = scope.severity(params[:severity]) if params[:severity].present?

@classifications = scope.group(:classification).count.reject { |k, _| k.nil? }.to_a.sort_by{|a| a[1]}.reverse
scope = scope.where(classification: params[:classification]) if params[:classification].present?

@ecosystems = scope.ecosystem_counts

@packages = scope.package_counts
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/ecosystems_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def show
@registry = Registry.find_by_ecosystem(@ecosystem)
scope = Advisory.not_withdrawn.ecosystem(@ecosystem)

@severities = scope.group(:severity).count.to_a.sort_by{|a| a[1]}.reverse
@severities = scope.group(:severity).count.reject { |k, _| k.nil? }.to_a.sort_by{|a| a[1]}.reverse
scope = scope.severity(params[:severity]) if params[:severity].present?

@packages = scope.package_counts
Expand Down Expand Up @@ -62,7 +62,7 @@ def package
@package = Package.find_by(ecosystem: @ecosystem, name: @package_name)
scope = Advisory.not_withdrawn.ecosystem(@ecosystem).package_name(@package_name)

@severities = scope.group(:severity).count.to_a.sort_by{|a| a[1]}.reverse
@severities = scope.group(:severity).count.reject { |k, _| k.nil? }.to_a.sort_by{|a| a[1]}.reverse
scope = scope.severity(params[:severity]) if params[:severity].present?

@repository_urls = scope.group(:repository_url).count.to_a.sort_by{|a| a[1]}.reverse
Expand Down
26 changes: 26 additions & 0 deletions app/controllers/osv/application_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
module Osv
class ApplicationController < ::ApplicationController
skip_forgery_protection

rescue_from ActionController::ParameterMissing, with: :bad_request
rescue_from ArgumentError, with: :bad_request

def json_params
return @json_params if defined?(@json_params)

# Use Rails-parsed params if JSON content type, otherwise try reading body
if request.content_type&.include?('application/json')
@json_params = params.except(:controller, :action, :format).to_unsafe_h.deep_symbolize_keys
else
body = request.body.read
@json_params = body.present? ? JSON.parse(body).deep_symbolize_keys : {}
end
rescue JSON::ParserError
@json_params = {}
end

def bad_request(exception)
render json: { error: exception.message }, status: :bad_request
end
end
end
9 changes: 9 additions & 0 deletions app/controllers/osv/query_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module Osv
class QueryController < ApplicationController
def create
result = QueryService.new(json_params).find_vulnerabilities
@advisories = result[:advisories]
@next_page_token = result[:next_page_token]
end
end
end
27 changes: 27 additions & 0 deletions app/controllers/osv/querybatch_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module Osv
class QuerybatchController < ApplicationController
MAX_BATCH_SIZE = 1000

def create
queries = json_params[:queries] || []

if queries.length > MAX_BATCH_SIZE
render json: { error: "Maximum batch size is #{MAX_BATCH_SIZE}" }, status: :bad_request
return
end

@results = queries.map do |query_params|
begin
result = QueryService.new(query_params).find_vulnerabilities
{
vulns: result[:advisories].map do |advisory|
VulnerabilityTransformer.new(advisory).transform(summary_only: true)
end
}
rescue StandardError => e
{ vulns: [], error: e.message }
end
end
end
end
end
11 changes: 11 additions & 0 deletions app/controllers/osv/vulns_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Osv
class VulnsController < ApplicationController
def show
@advisory = QueryService.find_by_id(params[:id])

if @advisory.nil?
render json: {}, status: :not_found
end
end
end
end
8 changes: 8 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module ApplicationHelper
include Pagy::Frontend

def severity_class(severity)
return 'text-bg-secondary' if severity.nil?
case severity.downcase
when 'low'
'bg-success'
Expand All @@ -17,6 +18,7 @@ def severity_class(severity)
end

def render_markdown(str)
return '' if str.nil?
Commonmarker.to_html(str)
end

Expand All @@ -41,4 +43,10 @@ def bootstrap_icon(symbol, options = {})
icon = BootstrapIcons::BootstrapIcon.new(symbol, options)
content_tag(:svg, icon.path.html_safe, icon.options)
end

def source_icon(source, options = {})
icon_name = source.is_a?(Source) ? source.icon : Source::ICONS[source]
icon_name ||= 'shield-exclamation'
bootstrap_icon(icon_name, options.merge(width: 18, height: 18, class: 'me-2'))
end
end
1 change: 1 addition & 0 deletions app/models/advisory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ def invalid_repository_urls
# TODO store affected_dependent_packages_count and affected_dependent_versions_count in the database and sync on a regular basis

def enqueue_package_sync
return if Rails.env.development?
packages.each do |package|
PackageSyncWorker.perform_async(package['ecosystem'], package['package_name'])
end
Expand Down
106 changes: 106 additions & 0 deletions app/models/osv/query_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
module Osv
class QueryService
ECOSYSTEM_MAPPING = VulnerabilityTransformer::ECOSYSTEM_MAPPING.invert.freeze
PAGE_SIZE = 100

attr_reader :params

def initialize(params)
@params = params
end

def find_vulnerabilities
scope = build_scope
paginate(scope)
end

def build_scope
scope = Advisory.all

if params[:purl].present?
return find_by_purl(params[:purl])
end

if params[:package].present?
scope = find_by_package(params[:package], scope)
end

scope.order(updated_at: :desc)
end

def find_by_purl(purl_string)
parsed = PurlParser.parse(purl_string)
return Advisory.none unless parsed

# Check if version is also in query params - that's an error
if params.dig(:package, :version).present?
raise ArgumentError, "version cannot be specified in both purl and package parameters"
end

Advisory.ecosystem(parsed[:ecosystem])
.package_name(parsed[:package_name])
.order(updated_at: :desc)
end

def find_by_package(package_params, scope)
ecosystem = package_params[:ecosystem]
name = package_params[:name]

return scope if ecosystem.blank? && name.blank?

if ecosystem.present?
internal_ecosystem = normalize_ecosystem(ecosystem)
scope = scope.ecosystem(internal_ecosystem)
end

scope = scope.package_name(name) if name.present?

scope
end

def normalize_ecosystem(ecosystem)
ECOSYSTEM_MAPPING[ecosystem] || ecosystem.downcase
end

def paginate(scope)
page_token = params[:page_token]
offset = decode_page_token(page_token)

results = scope.offset(offset).limit(PAGE_SIZE + 1).to_a

next_page_token = nil
if results.size > PAGE_SIZE
results.pop
next_page_token = encode_page_token(offset + PAGE_SIZE)
end

{
advisories: results,
next_page_token: next_page_token
}
end

def encode_page_token(offset)
return nil if offset <= 0
Base64.urlsafe_encode64(offset.to_s, padding: false)
end

def decode_page_token(token)
return 0 if token.blank?
Base64.urlsafe_decode64(token).to_i
rescue ArgumentError
0
end

def self.find_by_id(id)
# Try finding by uuid first
advisory = Advisory.find_by(uuid: id)
return advisory if advisory

# Try finding by CVE identifier
if id.start_with?('CVE-')
Advisory.where("? = ANY(identifiers)", id).first
end
end
end
end
Loading