diff --git a/Gemfile b/Gemfile index 7b0dbbc..3b5cc9e 100644 --- a/Gemfile +++ b/Gemfile @@ -49,6 +49,7 @@ gem 'sidekiq' gem 'sidekiq-unique-jobs' gem 'appsignal' gem 'dalli' +gem 'rubyzip' group :development, :test do gem 'dotenv-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 2808717..d08abe8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -348,6 +349,7 @@ DEPENDENCIES redis rswag-api rswag-ui + rubyzip sassc-rails secure_headers semantic @@ -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 diff --git a/README.md b/README.md index df071d6..1b75cd5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/controllers/advisories_controller.rb b/app/controllers/advisories_controller.rb index 0586c1e..c2a70eb 100644 --- a/app/controllers/advisories_controller.rb +++ b/app/controllers/advisories_controller.rb @@ -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 diff --git a/app/controllers/ecosystems_controller.rb b/app/controllers/ecosystems_controller.rb index 74bdb8e..ef2a273 100644 --- a/app/controllers/ecosystems_controller.rb +++ b/app/controllers/ecosystems_controller.rb @@ -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 @@ -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 diff --git a/app/controllers/osv/application_controller.rb b/app/controllers/osv/application_controller.rb new file mode 100644 index 0000000..ae07870 --- /dev/null +++ b/app/controllers/osv/application_controller.rb @@ -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 diff --git a/app/controllers/osv/query_controller.rb b/app/controllers/osv/query_controller.rb new file mode 100644 index 0000000..aac9957 --- /dev/null +++ b/app/controllers/osv/query_controller.rb @@ -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 diff --git a/app/controllers/osv/querybatch_controller.rb b/app/controllers/osv/querybatch_controller.rb new file mode 100644 index 0000000..d2e5d2a --- /dev/null +++ b/app/controllers/osv/querybatch_controller.rb @@ -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 diff --git a/app/controllers/osv/vulns_controller.rb b/app/controllers/osv/vulns_controller.rb new file mode 100644 index 0000000..bada61c --- /dev/null +++ b/app/controllers/osv/vulns_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2250ae0..59c4678 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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' @@ -17,6 +18,7 @@ def severity_class(severity) end def render_markdown(str) + return '' if str.nil? Commonmarker.to_html(str) end @@ -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 diff --git a/app/models/advisory.rb b/app/models/advisory.rb index 464649d..0157173 100644 --- a/app/models/advisory.rb +++ b/app/models/advisory.rb @@ -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 diff --git a/app/models/osv/query_service.rb b/app/models/osv/query_service.rb new file mode 100644 index 0000000..9c73f90 --- /dev/null +++ b/app/models/osv/query_service.rb @@ -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 diff --git a/app/models/osv/vulnerability_transformer.rb b/app/models/osv/vulnerability_transformer.rb new file mode 100644 index 0000000..68214a4 --- /dev/null +++ b/app/models/osv/vulnerability_transformer.rb @@ -0,0 +1,217 @@ +module Osv + class VulnerabilityTransformer + ECOSYSTEM_MAPPING = { + 'npm' => 'npm', + 'pypi' => 'PyPI', + 'rubygems' => 'RubyGems', + 'maven' => 'Maven', + 'nuget' => 'NuGet', + 'go' => 'Go', + 'cargo' => 'crates.io', + 'packagist' => 'Packagist', + 'hex' => 'Hex', + 'pub' => 'Pub', + 'actions' => 'GitHub Actions', + 'cran' => 'CRAN', + 'ghc' => 'GHC', + 'hackage' => 'Hackage', + 'julia' => 'Julia', + 'swift' => 'SwiftURL', + 'debian' => 'Debian', + 'alpine' => 'Alpine', + 'ubuntu' => 'Ubuntu', + 'almalinux' => 'AlmaLinux', + 'rocky' => 'Rocky Linux', + 'suse' => 'SUSE', + 'opensuse' => 'openSUSE', + 'redhat' => 'Red Hat', + 'mageia' => 'Mageia', + 'openeuler' => 'openEuler', + 'wolfi' => 'Wolfi', + 'chainguard' => 'Chainguard', + 'bitnami' => 'Bitnami', + 'android' => 'Android', + 'linux' => 'Linux', + 'oss-fuzz' => 'OSS-Fuzz', + 'git' => 'GIT' + }.freeze + + SUMMARY_MAX_LENGTH = 120 + + attr_reader :advisory + + def initialize(advisory) + @advisory = advisory + end + + def transform(summary_only: false) + if summary_only + summary_format + else + full_format + end + end + + def summary_format + { + id: advisory.uuid, + modified: format_timestamp(advisory.updated_at) + } + end + + def full_format + result = { + id: advisory.uuid, + summary: truncate_summary(advisory.title), + details: advisory.description, + aliases: build_aliases, + modified: format_timestamp(advisory.updated_at), + published: format_timestamp(advisory.published_at), + references: build_references, + affected: build_affected, + database_specific: build_database_specific + } + + result[:withdrawn] = format_timestamp(advisory.withdrawn_at) if advisory.withdrawn_at.present? + result[:severity] = build_severity if advisory.cvss_vector.present? + + result + end + + def build_aliases + advisory.identifiers.reject { |id| id == advisory.uuid } + end + + def truncate_summary(title) + return nil if title.blank? + return title if title.length <= SUMMARY_MAX_LENGTH + title[0, SUMMARY_MAX_LENGTH - 3] + '...' + end + + def build_references + return [] if advisory.references.blank? + + advisory.references.map do |url| + { + type: infer_reference_type(url), + url: url + } + end + end + + def infer_reference_type(url) + case url.downcase + when /advisory|security|vuln/i then 'ADVISORY' + when /fix|commit|pull|pr|patch/i then 'FIX' + when /github\.com\/[^\/]+\/[^\/]+$/i then 'PACKAGE' + when /report|disclosure/i then 'REPORT' + when /article|blog/i then 'ARTICLE' + when /cve\.org|nvd\.nist\.gov/i then 'ADVISORY' + else 'WEB' + end + end + + def build_affected + return [] if advisory.packages.blank? + + advisory.packages.map do |package| + ecosystem = map_ecosystem(package['ecosystem']) + { + package: { + name: package['package_name'], + ecosystem: ecosystem + }, + ranges: build_ranges(package['versions'] || []) + } + end + end + + def build_ranges(versions) + versions.filter_map do |version| + range = version['vulnerable_version_range'] + next unless range + + events = parse_range_to_events(range, version['first_patched_version']) + next if events.empty? + + { + type: 'ECOSYSTEM', + events: events + } + end + end + + def parse_range_to_events(range, first_patched) + events = [] + + # Split multiple constraints (e.g., ">= 1.0, < 2.0") + constraints = range.split(/\s*,\s*/) + + constraints.each do |constraint| + constraint = constraint.strip + + case constraint + when /^>=\s*(.+)$/ + events << { introduced: $1.strip } + when /^>\s*(.+)$/ + events << { introduced: $1.strip } + when /^<\s*(.+)$/ + events << { fixed: $1.strip } + when /^<=\s*(.+)$/ + # For <= we can't directly represent "fixed" since fixed is exclusive + # We include the version as fixed (approximation) + events << { fixed: $1.strip } + end + end + + # Add introduced: "0" if only a fixed constraint exists + if events.any? { |e| e.key?(:fixed) } && events.none? { |e| e.key?(:introduced) } + events.unshift({ introduced: '0' }) + end + + # Use first_patched_version if available and no fixed event yet + if first_patched.present? && events.none? { |e| e.key?(:fixed) } + events << { fixed: first_patched } + end + + events + end + + def build_severity + [ + { + type: detect_cvss_version(advisory.cvss_vector), + score: advisory.cvss_vector + } + ] + end + + def detect_cvss_version(vector) + return 'CVSS_V4' if vector&.start_with?('CVSS:4') + return 'CVSS_V3' if vector&.start_with?('CVSS:3') + 'CVSS_V3' # Default to V3 + end + + def build_database_specific + { + source: advisory.source_kind, + url: advisory.url, + severity: advisory.severity, + cvss_score: advisory.cvss_score, + epss_percentage: advisory.epss_percentage, + epss_percentile: advisory.epss_percentile, + blast_radius: advisory.blast_radius + }.compact + end + + def map_ecosystem(ecosystem) + return nil if ecosystem.blank? + ECOSYSTEM_MAPPING[ecosystem.downcase] || ecosystem + end + + def format_timestamp(timestamp) + return nil if timestamp.blank? + timestamp.utc.iso8601 + end + end +end diff --git a/app/models/source.rb b/app/models/source.rb index 20ec368..94c604d 100644 --- a/app/models/source.rb +++ b/app/models/source.rb @@ -18,4 +18,14 @@ def source_class def sync_advisories source_instance.sync_advisories end + + ICONS = { + 'github' => 'github', + 'osv' => 'google', + 'erlef' => 'hexagon' + }.freeze + + def icon + ICONS[kind] + end end diff --git a/app/models/sources/osv.rb b/app/models/sources/osv.rb new file mode 100644 index 0000000..4dc9a48 --- /dev/null +++ b/app/models/sources/osv.rb @@ -0,0 +1,337 @@ +require 'zip' +require 'cvss_suite' + +module Sources + class Osv < Base + BASE_URL = 'https://storage.googleapis.com/osv-vulnerabilities'.freeze + + ECOSYSTEM_MAPPING = { + 'crates.io' => 'cargo', + 'PyPI' => 'pypi', + 'RubyGems' => 'rubygems', + 'Maven' => 'maven', + 'NuGet' => 'nuget', + 'Packagist' => 'packagist', + 'Hex' => 'hex', + 'Pub' => 'pub', + 'Go' => 'go', + 'npm' => 'npm', + 'GitHub Actions' => 'actions', + 'CRAN' => 'cran', + 'GHC' => 'ghc', + 'Hackage' => 'hackage', + 'Julia' => 'julia', + 'SwiftURL' => 'swift', + 'Debian' => 'debian', + 'Alpine' => 'alpine', + 'Ubuntu' => 'ubuntu', + 'AlmaLinux' => 'almalinux', + 'Rocky Linux' => 'rocky', + 'SUSE' => 'suse', + 'openSUSE' => 'opensuse', + 'Red Hat' => 'redhat', + 'Mageia' => 'mageia', + 'openEuler' => 'openeuler', + 'Wolfi' => 'wolfi', + 'Chainguard' => 'chainguard', + 'Bitnami' => 'bitnami', + 'Android' => 'android', + 'Linux' => 'linux', + 'OSS-Fuzz' => 'oss-fuzz', + 'GIT' => 'git', + 'MinimOS' => 'minimos' + }.freeze + + EXCLUDED_ECOSYSTEMS = %w[ + Debian + Ubuntu + Alpine + AlmaLinux + Alpaquita + Rocky\ Linux + SUSE + openSUSE + Red\ Hat + Mageia + openEuler + Wolfi + Chainguard + Bitnami + Linux + Android + MinimOS + BellSoft\ Hardened\ Containers + Echo + ].freeze + + def fetch_ecosystems + response = Faraday.get("#{BASE_URL}/ecosystems.txt") + return [] unless response.success? + + response.body.split("\n").map(&:strip).reject(&:empty?).reject { |e| EXCLUDED_ECOSYSTEMS.include?(e) } + end + + def fetch_advisories + ecosystems = fetch_ecosystems + advisories = [] + + ecosystems.each do |ecosystem| + Rails.logger.info "Fetching OSV advisories for ecosystem: #{ecosystem}" + ecosystem_advisories = fetch_ecosystem_advisories(ecosystem) + advisories.concat(ecosystem_advisories) + end + + advisories + end + + def fetch_ecosystem_advisories(ecosystem) + url = ecosystem_zip_url(ecosystem) + response = Faraday.get(url) + return [] unless response.success? + + advisories = [] + Tempfile.create(['osv', '.zip']) do |tempfile| + tempfile.binmode + tempfile.write(response.body) + tempfile.rewind + + Zip::File.open(tempfile.path) do |zip| + zip.each do |entry| + next unless entry.name.end_with?('.json') + content = entry.get_input_stream.read + osv = JSON.parse(content, symbolize_names: true) + advisories << osv + end + end + end + advisories + rescue => e + Rails.logger.error "Failed to fetch OSV advisories for #{ecosystem}: #{e.message}" + [] + end + + def ecosystem_zip_url(ecosystem) + encoded = URI.encode_uri_component(ecosystem) + "#{BASE_URL}/#{encoded}/all.zip" + end + + def sync_advisories + ecosystems = fetch_ecosystems + total_synced = 0 + packages_to_sync = Set.new + + ecosystems.each do |ecosystem| + Rails.logger.info "Syncing OSV advisories for ecosystem: #{ecosystem}" + count = sync_ecosystem(ecosystem, packages_to_sync) + total_synced += count + Rails.logger.info "Synced #{count} advisories for #{ecosystem} (#{total_synced} total)" + end + + unless Rails.env.development? + Rails.logger.info "Enqueueing sync jobs for #{packages_to_sync.size} unique packages" + packages_to_sync.each do |pkg_ecosystem, package_name| + PackageSyncWorker.perform_async(pkg_ecosystem, package_name) + end + end + + total_synced + end + + def sync_ecosystem(ecosystem, packages_to_sync) + advisories = fetch_ecosystem_advisories(ecosystem) + return 0 if advisories.empty? + + mapped_advisories = map_advisories(advisories) + return 0 if mapped_advisories.empty? + + uuids = mapped_advisories.map { |a| a[:uuid] } + existing_advisories = source.advisories.where(uuid: uuids).index_by(&:uuid) + + records_to_upsert = [] + mapped_advisories.each do |advisory| + existing = existing_advisories[advisory[:uuid]] + + if existing.nil? || advisory_changed?(existing, advisory) + advisory[:packages].each do |pkg| + packages_to_sync.add([pkg[:ecosystem], pkg[:package_name]]) + end + + records_to_upsert << advisory.merge( + source_id: source.id, + created_at: existing&.created_at || Time.current, + updated_at: Time.current + ) + end + end + + if records_to_upsert.any? + new_records = records_to_upsert.select { |r| existing_advisories[r[:uuid]].nil? } + existing_records = records_to_upsert.reject { |r| existing_advisories[r[:uuid]].nil? } + + Advisory.insert_all(new_records) if new_records.any? + + existing_records.each do |record| + existing = existing_advisories[record[:uuid]] + existing.update_columns(record.except(:source_id, :created_at, :uuid)) + end + end + + mapped_advisories.count + end + + def advisory_changed?(existing, new_attrs) + existing.assign_attributes(new_attrs.except(:source_id, :created_at, :uuid)) + changed = existing.changed? && (existing.changed - ['updated_at']).any? + existing.restore_attributes + changed + end + + def map_advisories(advisories) + advisories.filter_map { |osv| map_osv_advisory(osv) } + end + + def map_osv_advisory(osv) + return nil unless osv[:summary].present? || osv[:details].present? + + packages = extract_packages(osv[:affected] || []) + + cvss_vector = extract_cvss_vector(osv[:severity]) + cvss_score = parse_cvss_score(cvss_vector) + + { + uuid: osv[:id], + url: extract_url(osv), + title: osv[:summary], + description: osv[:details], + origin: 'OSV', + severity: severity_from_score(cvss_score), + published_at: osv[:published], + updated_at: osv[:modified], + withdrawn_at: osv[:withdrawn], + classification: extract_classification(osv[:id]), + cvss_score: cvss_score, + cvss_vector: cvss_vector, + references: extract_references(osv[:references] || []), + source_kind: 'osv', + identifiers: extract_identifiers(osv), + epss_percentage: nil, + epss_percentile: nil, + packages: packages + } + end + + def extract_url(osv) + advisory_ref = (osv[:references] || []).find { |r| r[:type] == 'ADVISORY' } + advisory_ref&.dig(:url) || "https://osv.dev/vulnerability/#{osv[:id]}" + end + + def extract_cvss_vector(severity) + return nil unless severity.is_a?(Array) + + cvss4 = severity.find { |s| s[:type] == 'CVSS_V4' } + return cvss4[:score] if cvss4 + + cvss31 = severity.find { |s| s[:type] == 'CVSS_V3' } + cvss31&.dig(:score) + end + + def extract_packages(affected) + affected.filter_map do |entry| + next unless entry[:package] + + ecosystem = correct_ecosystem(entry.dig(:package, :ecosystem)) + package_name = entry.dig(:package, :name) + next unless ecosystem && package_name + + versions = extract_version_ranges(entry[:ranges] || []) + next if versions.empty? + + { + ecosystem: ecosystem, + package_name: package_name, + versions: versions + } + end + end + + def extract_version_ranges(ranges) + ranges.filter_map do |range| + next unless range[:type] == 'SEMVER' || range[:type] == 'ECOSYSTEM' + + events = range[:events] || [] + introduced = events.find { |e| e[:introduced] }&.dig(:introduced) + fixed = events.find { |e| e[:fixed] }&.dig(:fixed) + + vulnerable_range = build_version_range(introduced, fixed) + next unless vulnerable_range + + { + vulnerable_version_range: vulnerable_range, + first_patched_version: fixed + } + end + end + + def build_version_range(introduced, fixed) + return nil unless introduced + + if introduced == '0' && fixed + "< #{fixed}" + elsif introduced != '0' && fixed + ">= #{introduced}, < #{fixed}" + elsif introduced != '0' && fixed.nil? + ">= #{introduced}" + elsif introduced == '0' && fixed.nil? + ">= 0" + end + end + + def extract_references(references) + references.map { |r| r[:url] }.compact + end + + def extract_identifiers(osv) + ids = [osv[:id]] + ids += osv[:aliases] || [] + ids.compact.uniq + end + + def correct_ecosystem(ecosystem) + return nil unless ecosystem + + # Strip URL suffixes (e.g., "packagist:https://packages.drupal.org/8" -> "packagist") + # Strip version suffixes (e.g., "Debian:12" -> "Debian", "Alpine:v3.17" -> "Alpine") + base_ecosystem = ecosystem.split(':').first + + # Check if this is an excluded ecosystem + return nil if EXCLUDED_ECOSYSTEMS.any? { |e| base_ecosystem.casecmp?(e.gsub('\\', '')) } + + ECOSYSTEM_MAPPING[base_ecosystem] || base_ecosystem.downcase.gsub(/\s+/, '-') + end + + def extract_classification(id) + return 'MALWARE' if id&.start_with?('MAL-') + nil + end + + def parse_cvss_score(vector) + return nil unless vector + + cvss = CvssSuite.new(vector) + return nil unless cvss.valid? + + cvss.overall_score + end + + def severity_from_score(score) + return nil unless score + + case score + when 9.0..10.0 then 'CRITICAL' + when 7.0...9.0 then 'HIGH' + when 4.0...7.0 then 'MEDIUM' + when 0.1...4.0 then 'LOW' + end + end + end +end diff --git a/app/views/advisories/_advisory.html.erb b/app/views/advisories/_advisory.html.erb index b90b8eb..361b890 100644 --- a/app/views/advisories/_advisory.html.erb +++ b/app/views/advisories/_advisory.html.erb @@ -20,15 +20,17 @@