From 0911193ecbe8682cd447ebe4253e933f07317497 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 20 Jan 2026 13:19:12 +0000 Subject: [PATCH 1/2] Add OSV compatible api endpoints and spec doc --- README.md | 20 +- app/controllers/osv/application_controller.rb | 26 ++ app/controllers/osv/query_controller.rb | 9 + app/controllers/osv/querybatch_controller.rb | 27 ++ app/controllers/osv/vulns_controller.rb | 11 + app/models/osv/query_service.rb | 106 +++++ app/models/osv/vulnerability_transformer.rb | 217 ++++++++++ app/views/osv/query/create.json.jbuilder | 6 + app/views/osv/querybatch/create.json.jbuilder | 1 + app/views/osv/vulns/show.json.jbuilder | 4 + config/initializers/rswag-ui.rb | 1 + config/routes.rb | 6 + openapi/osv/v1/openapi.yaml | 404 ++++++++++++++++++ test/controllers/osv/query_controller_test.rb | 158 +++++++ .../osv/querybatch_controller_test.rb | 146 +++++++ test/controllers/osv/vulns_controller_test.rb | 145 +++++++ test/models/osv/query_service_test.rb | 190 ++++++++ .../osv/vulnerability_transformer_test.rb | 280 ++++++++++++ 18 files changed, 1756 insertions(+), 1 deletion(-) create mode 100644 app/controllers/osv/application_controller.rb create mode 100644 app/controllers/osv/query_controller.rb create mode 100644 app/controllers/osv/querybatch_controller.rb create mode 100644 app/controllers/osv/vulns_controller.rb create mode 100644 app/models/osv/query_service.rb create mode 100644 app/models/osv/vulnerability_transformer.rb create mode 100644 app/views/osv/query/create.json.jbuilder create mode 100644 app/views/osv/querybatch/create.json.jbuilder create mode 100644 app/views/osv/vulns/show.json.jbuilder create mode 100644 openapi/osv/v1/openapi.yaml create mode 100644 test/controllers/osv/query_controller_test.rb create mode 100644 test/controllers/osv/querybatch_controller_test.rb create mode 100644 test/controllers/osv/vulns_controller_test.rb create mode 100644 test/models/osv/query_service_test.rb create mode 100644 test/models/osv/vulnerability_transformer_test.rb diff --git a/README.md b/README.md index df071d66..1b75cd5c 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/osv/application_controller.rb b/app/controllers/osv/application_controller.rb new file mode 100644 index 00000000..ae078704 --- /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 00000000..aac99575 --- /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 00000000..d2e5d2a9 --- /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 00000000..bada61cf --- /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/models/osv/query_service.rb b/app/models/osv/query_service.rb new file mode 100644 index 00000000..9c73f90c --- /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 00000000..68214a4e --- /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/views/osv/query/create.json.jbuilder b/app/views/osv/query/create.json.jbuilder new file mode 100644 index 00000000..377d03cd --- /dev/null +++ b/app/views/osv/query/create.json.jbuilder @@ -0,0 +1,6 @@ +json.vulns @advisories do |advisory| + transformer = Osv::VulnerabilityTransformer.new(advisory) + json.merge! transformer.transform +end + +json.next_page_token @next_page_token if @next_page_token.present? diff --git a/app/views/osv/querybatch/create.json.jbuilder b/app/views/osv/querybatch/create.json.jbuilder new file mode 100644 index 00000000..bb0ab94b --- /dev/null +++ b/app/views/osv/querybatch/create.json.jbuilder @@ -0,0 +1 @@ +json.results @results diff --git a/app/views/osv/vulns/show.json.jbuilder b/app/views/osv/vulns/show.json.jbuilder new file mode 100644 index 00000000..793967a2 --- /dev/null +++ b/app/views/osv/vulns/show.json.jbuilder @@ -0,0 +1,4 @@ +transformer = Osv::VulnerabilityTransformer.new(@advisory) +osv = transformer.transform + +json.merge! osv diff --git a/config/initializers/rswag-ui.rb b/config/initializers/rswag-ui.rb index e2612201..d423f9c6 100644 --- a/config/initializers/rswag-ui.rb +++ b/config/initializers/rswag-ui.rb @@ -7,6 +7,7 @@ # then the list below should correspond to the relative paths for those endpoints c.openapi_endpoint '/docs/api/v1/openapi.yaml', 'API V1 Docs' + c.openapi_endpoint '/docs/osv/v1/openapi.yaml', 'OSV API V1 Docs' # Add Basic Auth in case your API is private # c.basic_auth_enabled = true diff --git a/config/routes.rb b/config/routes.rb index 07f031f6..5847f0d7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,6 +13,12 @@ mount Sidekiq::Web => "/sidekiq" mount PgHero::Engine, at: "pghero" + namespace :osv, path: 'v1', defaults: { format: :json } do + post 'query', to: 'query#create' + post 'querybatch', to: 'querybatch#create' + get 'vulns/:id', to: 'vulns#show', as: :vuln, id: /[^\/]+/ + end + namespace :api, :defaults => {:format => :json} do namespace :v1 do resources :advisories, only: [:index, :show] do diff --git a/openapi/osv/v1/openapi.yaml b/openapi/osv/v1/openapi.yaml new file mode 100644 index 00000000..46af6018 --- /dev/null +++ b/openapi/osv/v1/openapi.yaml @@ -0,0 +1,404 @@ +openapi: 3.0.1 +info: + title: "Ecosyste.ms: Advisories OSV API" + description: "An OSV-compatible API for querying security vulnerabilities. This API follows the OSV (Open Source Vulnerabilities) specification for interoperability with other vulnerability databases." + contact: + name: Ecosyste.ms + email: support@ecosyste.ms + url: https://ecosyste.ms + version: 1.0.0 + license: + name: CC-BY-SA-4.0 + url: https://creativecommons.org/licenses/by-sa/4.0/ +externalDocs: + description: OSV Schema Specification + url: https://ossf.github.io/osv-schema/ +servers: +- url: https://advisories.ecosyste.ms/v1 +paths: + /query: + post: + summary: "Query vulnerabilities for a package" + operationId: "queryVulnerabilities" + description: "Query for vulnerabilities affecting a specific package. You can query by package name and ecosystem, or by Package URL (PURL)." + tags: + - query + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QueryRequest' + examples: + byPackage: + summary: Query by package name and ecosystem + value: + package: + name: lodash + ecosystem: npm + byPurl: + summary: Query by PURL + value: + purl: "pkg:npm/lodash@4.17.20" + withPagination: + summary: Query with pagination + value: + package: + name: lodash + ecosystem: npm + page_token: "MTAw" + responses: + 200: + description: "List of vulnerabilities matching the query" + content: + application/json: + schema: + $ref: '#/components/schemas/QueryResponse' + 400: + description: "Bad Request" + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /querybatch: + post: + summary: "Batch query vulnerabilities" + operationId: "queryBatchVulnerabilities" + description: "Query for vulnerabilities affecting multiple packages in a single request. Maximum 1000 queries per batch. Returns summary format (id and modified only) for each vulnerability." + tags: + - query + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchQueryRequest' + example: + queries: + - package: + name: lodash + ecosystem: npm + - package: + name: django + ecosystem: PyPI + - purl: "pkg:gem/rails" + responses: + 200: + description: "Batch query results" + content: + application/json: + schema: + $ref: '#/components/schemas/BatchQueryResponse' + 400: + description: "Bad Request - batch size exceeds 1000" + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /vulns/{id}: + get: + summary: "Get vulnerability by ID" + operationId: "getVulnerability" + description: "Retrieve a specific vulnerability by its ID. The ID can be a vulnerability UUID (e.g., GHSA-xxxx-yyyy-zzzz) or a CVE identifier (e.g., CVE-2023-12345)." + tags: + - vulnerabilities + parameters: + - in: path + name: id + schema: + type: string + required: true + description: "Vulnerability ID (UUID or CVE identifier)" + examples: + ghsa: + value: "GHSA-xxxx-yyyy-zzzz" + summary: GitHub Security Advisory ID + cve: + value: "CVE-2023-12345" + summary: CVE identifier + responses: + 200: + description: "Vulnerability details in OSV format" + content: + application/json: + schema: + $ref: '#/components/schemas/Vulnerability' + 404: + description: "Vulnerability not found" + content: + application/json: + schema: + type: object +components: + schemas: + QueryRequest: + type: object + properties: + package: + $ref: '#/components/schemas/PackageQuery' + purl: + type: string + description: "Package URL (PURL) to query. Cannot specify version in both purl and package." + example: "pkg:npm/lodash@4.17.20" + page_token: + type: string + description: "Pagination token from previous response" + PackageQuery: + type: object + properties: + name: + type: string + description: "Package name" + example: "lodash" + ecosystem: + type: string + description: "Package ecosystem (OSV ecosystem names)" + example: "npm" + enum: + - npm + - PyPI + - RubyGems + - Maven + - NuGet + - Go + - crates.io + - Packagist + - Hex + - Pub + - GitHub Actions + - CRAN + - Hackage + - Debian + - Alpine + - Ubuntu + version: + type: string + description: "Package version (optional)" + example: "4.17.20" + QueryResponse: + type: object + properties: + vulns: + type: array + items: + $ref: '#/components/schemas/Vulnerability' + next_page_token: + type: string + description: "Token for fetching the next page of results" + BatchQueryRequest: + type: object + required: + - queries + properties: + queries: + type: array + maxItems: 1000 + items: + $ref: '#/components/schemas/QueryRequest' + BatchQueryResponse: + type: object + properties: + results: + type: array + items: + type: object + properties: + vulns: + type: array + items: + $ref: '#/components/schemas/VulnerabilitySummary' + VulnerabilitySummary: + type: object + description: "Summary format vulnerability (batch query response)" + required: + - id + - modified + properties: + id: + type: string + description: "Unique identifier for the vulnerability" + example: "GHSA-xxxx-yyyy-zzzz" + modified: + type: string + format: date-time + description: "Last modification timestamp (ISO 8601)" + example: "2023-01-15T10:00:00Z" + Vulnerability: + type: object + description: "Full OSV format vulnerability" + required: + - id + - modified + properties: + id: + type: string + description: "Unique identifier for the vulnerability" + example: "GHSA-xxxx-yyyy-zzzz" + summary: + type: string + description: "Short summary of the vulnerability (max 120 characters)" + example: "Prototype Pollution in lodash" + details: + type: string + description: "Detailed description of the vulnerability" + aliases: + type: array + description: "Other identifiers for this vulnerability (CVEs, other database IDs)" + items: + type: string + example: ["CVE-2023-12345"] + modified: + type: string + format: date-time + description: "Last modification timestamp (ISO 8601)" + example: "2023-01-15T10:00:00Z" + published: + type: string + format: date-time + description: "Publication timestamp (ISO 8601)" + example: "2023-01-10T08:00:00Z" + withdrawn: + type: string + format: date-time + description: "Withdrawal timestamp if vulnerability was withdrawn (ISO 8601)" + references: + type: array + description: "Related URLs and references" + items: + $ref: '#/components/schemas/Reference' + affected: + type: array + description: "Affected packages and version ranges" + items: + $ref: '#/components/schemas/Affected' + severity: + type: array + description: "CVSS severity scores" + items: + $ref: '#/components/schemas/Severity' + database_specific: + type: object + description: "Additional metadata specific to this database" + properties: + source: + type: string + description: "Source database kind" + example: "github" + url: + type: string + description: "URL to the original advisory" + severity: + type: string + description: "Severity level" + enum: [critical, high, medium, low] + cvss_score: + type: number + description: "CVSS score" + example: 9.8 + epss_percentage: + type: number + description: "EPSS percentage" + epss_percentile: + type: number + description: "EPSS percentile" + blast_radius: + type: number + description: "Calculated blast radius score" + Reference: + type: object + properties: + type: + type: string + description: "Reference type" + enum: + - ADVISORY + - ARTICLE + - DETECTION + - DISCUSSION + - REPORT + - FIX + - GIT + - INTRODUCED + - PACKAGE + - EVIDENCE + - WEB + example: "ADVISORY" + url: + type: string + description: "Reference URL" + example: "https://github.com/advisories/GHSA-xxxx-yyyy-zzzz" + Affected: + type: object + properties: + package: + type: object + properties: + name: + type: string + description: "Package name" + example: "lodash" + ecosystem: + type: string + description: "Package ecosystem (OSV ecosystem name)" + example: "npm" + ranges: + type: array + description: "Affected version ranges" + items: + $ref: '#/components/schemas/Range' + Range: + type: object + properties: + type: + type: string + description: "Range type" + enum: + - SEMVER + - ECOSYSTEM + - GIT + example: "ECOSYSTEM" + events: + type: array + description: "Version events defining the affected range" + items: + $ref: '#/components/schemas/Event' + Event: + type: object + description: "A version event (introduced, fixed, last_affected, or limit)" + properties: + introduced: + type: string + description: "Version where vulnerability was introduced" + example: "1.0.0" + fixed: + type: string + description: "Version where vulnerability was fixed" + example: "4.17.21" + last_affected: + type: string + description: "Last affected version" + limit: + type: string + description: "Upper limit version" + Severity: + type: object + properties: + type: + type: string + description: "CVSS version" + enum: + - CVSS_V2 + - CVSS_V3 + - CVSS_V4 + example: "CVSS_V3" + score: + type: string + description: "CVSS vector string" + example: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + Error: + type: object + properties: + error: + type: string + description: "Error message" + example: "version cannot be specified in both purl and package parameters" diff --git a/test/controllers/osv/query_controller_test.rb b/test/controllers/osv/query_controller_test.rb new file mode 100644 index 00000000..eb84da66 --- /dev/null +++ b/test/controllers/osv/query_controller_test.rb @@ -0,0 +1,158 @@ +require "test_helper" + +class Osv::QueryControllerTest < ActionDispatch::IntegrationTest + setup do + @source = create(:source) + @npm_advisory = create(:advisory, + source: @source, + uuid: "GHSA-npm-0001", + title: "NPM Advisory", + packages: [ + { + "ecosystem" => "npm", + "package_name" => "lodash", + "versions" => [ + { "vulnerable_version_range" => "< 4.17.21" } + ] + } + ] + ) + @pypi_advisory = create(:advisory, + source: @source, + uuid: "GHSA-pypi-0001", + title: "PyPI Advisory", + packages: [ + { + "ecosystem" => "pypi", + "package_name" => "django", + "versions" => [ + { "vulnerable_version_range" => "< 3.2.0" } + ] + } + ] + ) + end + + test "queries by package name and ecosystem" do + post osv_query_url, params: { package: { name: "lodash", ecosystem: "npm" } }, as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 1, json["vulns"].length + assert_equal "GHSA-npm-0001", json["vulns"].first["id"] + end + + test "queries by PURL" do + post osv_query_url, params: { purl: "pkg:npm/lodash@4.17.20" }, as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 1, json["vulns"].length + assert_equal "GHSA-npm-0001", json["vulns"].first["id"] + end + + test "queries by PURL without version" do + post osv_query_url, params: { purl: "pkg:npm/lodash" }, as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 1, json["vulns"].length + end + + test "rejects version in both PURL and package parameter" do + post osv_query_url, + params: { purl: "pkg:npm/lodash@4.17.20", package: { version: "4.17.19" } }, + as: :json + assert_response :bad_request + + json = JSON.parse(response.body) + assert_includes json["error"], "version cannot be specified" + end + + test "returns empty results for unknown package" do + post osv_query_url, params: { package: { name: "nonexistent", ecosystem: "npm" } }, as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 0, json["vulns"].length + end + + test "handles OSV ecosystem names (PyPI)" do + post osv_query_url, params: { package: { name: "django", ecosystem: "PyPI" } }, as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 1, json["vulns"].length + assert_equal "GHSA-pypi-0001", json["vulns"].first["id"] + end + + test "handles OSV ecosystem names (RubyGems)" do + create(:advisory, + source: @source, + uuid: "GHSA-ruby-0001", + packages: [{ "ecosystem" => "rubygems", "package_name" => "rails", "versions" => [] }] + ) + + post osv_query_url, params: { package: { name: "rails", ecosystem: "RubyGems" } }, as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 1, json["vulns"].length + end + + test "returns OSV schema fields in response" do + post osv_query_url, params: { package: { name: "lodash", ecosystem: "npm" } }, as: :json + assert_response :success + + json = JSON.parse(response.body) + vuln = json["vulns"].first + + assert_includes vuln.keys, "id" + assert_includes vuln.keys, "summary" + assert_includes vuln.keys, "modified" + assert_includes vuln.keys, "affected" + assert_includes vuln.keys, "references" + end + + test "returns pagination token for large results" do + 102.times do |i| + create(:advisory, + source: @source, + uuid: "GHSA-page-#{i.to_s.rjust(4, '0')}", + packages: [{ "ecosystem" => "npm", "package_name" => "pagination-test", "versions" => [] }] + ) + end + + post osv_query_url, params: { package: { name: "pagination-test", ecosystem: "npm" } }, as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 100, json["vulns"].length + assert_not_nil json["next_page_token"] + + # Test second page + post osv_query_url, params: { package: { name: "pagination-test", ecosystem: "npm" }, page_token: json["next_page_token"] }, as: :json + assert_response :success + + json2 = JSON.parse(response.body) + assert_equal 2, json2["vulns"].length + assert_nil json2["next_page_token"] + end + + test "handles empty request body" do + post osv_query_url, params: {}, as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 2, json["vulns"].length + end + + test "ecosystem is case-sensitive (OSV spec)" do + # PyPI should work, pypi should also work (normalized internally) + post osv_query_url, params: { package: { name: "django", ecosystem: "pypi" } }, as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 1, json["vulns"].length + end +end diff --git a/test/controllers/osv/querybatch_controller_test.rb b/test/controllers/osv/querybatch_controller_test.rb new file mode 100644 index 00000000..ab60ec70 --- /dev/null +++ b/test/controllers/osv/querybatch_controller_test.rb @@ -0,0 +1,146 @@ +require "test_helper" + +class Osv::QuerybatchControllerTest < ActionDispatch::IntegrationTest + setup do + @source = create(:source) + @npm_advisory = create(:advisory, + source: @source, + uuid: "GHSA-npm-0001", + title: "NPM Advisory", + packages: [ + { "ecosystem" => "npm", "package_name" => "lodash", "versions" => [] } + ] + ) + @pypi_advisory = create(:advisory, + source: @source, + uuid: "GHSA-pypi-0001", + title: "PyPI Advisory", + packages: [ + { "ecosystem" => "pypi", "package_name" => "django", "versions" => [] } + ] + ) + end + + test "batch queries multiple packages" do + post osv_querybatch_url, + params: { + queries: [ + { package: { name: "lodash", ecosystem: "npm" } }, + { package: { name: "django", ecosystem: "PyPI" } } + ] + }, + as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 2, json["results"].length + assert_equal 1, json["results"][0]["vulns"].length + assert_equal 1, json["results"][1]["vulns"].length + end + + test "returns only id and modified in summary format" do + post osv_querybatch_url, + params: { + queries: [ + { package: { name: "lodash", ecosystem: "npm" } } + ] + }, + as: :json + assert_response :success + + json = JSON.parse(response.body) + vuln = json["results"][0]["vulns"][0] + + assert_equal "GHSA-npm-0001", vuln["id"] + assert_includes vuln.keys, "modified" + assert_equal 2, vuln.keys.length + refute_includes vuln.keys, "summary" + refute_includes vuln.keys, "details" + refute_includes vuln.keys, "affected" + end + + test "rejects more than 1000 queries" do + queries = 1001.times.map do |i| + { package: { name: "pkg-#{i}", ecosystem: "npm" } } + end + + post osv_querybatch_url, params: { queries: queries }, as: :json + assert_response :bad_request + + json = JSON.parse(response.body) + assert_includes json["error"], "Maximum batch size" + end + + test "handles empty queries array" do + post osv_querybatch_url, params: { queries: [] }, as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal [], json["results"] + end + + test "individual query errors do not fail batch" do + post osv_querybatch_url, + params: { + queries: [ + { package: { name: "lodash", ecosystem: "npm" } }, + { purl: "pkg:npm/test@1.0.0", package: { version: "2.0.0" } }, + { package: { name: "django", ecosystem: "PyPI" } } + ] + }, + as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 3, json["results"].length + + # First query should succeed + assert_equal 1, json["results"][0]["vulns"].length + + # Second query should have error + assert_equal 0, json["results"][1]["vulns"].length + + # Third query should succeed + assert_equal 1, json["results"][2]["vulns"].length + end + + test "handles PURL in batch queries" do + post osv_querybatch_url, + params: { + queries: [ + { purl: "pkg:npm/lodash" }, + { purl: "pkg:pypi/django" } + ] + }, + as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 2, json["results"].length + assert_equal 1, json["results"][0]["vulns"].length + assert_equal 1, json["results"][1]["vulns"].length + end + + test "handles missing queries key" do + post osv_querybatch_url, params: {}, as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal [], json["results"] + end + + test "returns empty vulns for unknown packages" do + post osv_querybatch_url, + params: { + queries: [ + { package: { name: "nonexistent", ecosystem: "npm" } } + ] + }, + as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 1, json["results"].length + assert_equal 0, json["results"][0]["vulns"].length + end +end diff --git a/test/controllers/osv/vulns_controller_test.rb b/test/controllers/osv/vulns_controller_test.rb new file mode 100644 index 00000000..48460850 --- /dev/null +++ b/test/controllers/osv/vulns_controller_test.rb @@ -0,0 +1,145 @@ +require "test_helper" + +class Osv::VulnsControllerTest < ActionDispatch::IntegrationTest + setup do + @source = create(:source) + @advisory = create(:advisory, + source: @source, + uuid: "GHSA-xxxx-yyyy-zzzz", + title: "Test Vulnerability", + description: "A test vulnerability description", + identifiers: ["GHSA-xxxx-yyyy-zzzz", "CVE-2023-12345"], + cvss_vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + cvss_score: 9.8, + severity: "critical", + references: ["https://github.com/example/advisory"], + packages: [ + { + "ecosystem" => "npm", + "package_name" => "lodash", + "versions" => [ + { "vulnerable_version_range" => ">= 1.0.0, < 4.17.21", "first_patched_version" => "4.17.21" } + ] + } + ], + published_at: Time.zone.parse("2023-01-15 10:00:00 UTC") + ) + end + + test "gets vulnerability by uuid" do + get osv_vuln_url("GHSA-xxxx-yyyy-zzzz"), as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal "GHSA-xxxx-yyyy-zzzz", json["id"] + assert_equal "Test Vulnerability", json["summary"] + assert_equal "A test vulnerability description", json["details"] + end + + test "gets vulnerability by CVE identifier" do + get osv_vuln_url("CVE-2023-12345"), as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal "GHSA-xxxx-yyyy-zzzz", json["id"] + end + + test "returns 404 for unknown ID" do + get osv_vuln_url("GHSA-nonexistent"), as: :json + assert_response :not_found + + json = JSON.parse(response.body) + assert_equal({}, json) + end + + test "returns full OSV format response" do + get osv_vuln_url("GHSA-xxxx-yyyy-zzzz"), as: :json + assert_response :success + + json = JSON.parse(response.body) + + # Required OSV fields + assert_includes json.keys, "id" + assert_includes json.keys, "modified" + assert_includes json.keys, "published" + assert_includes json.keys, "summary" + assert_includes json.keys, "details" + assert_includes json.keys, "aliases" + assert_includes json.keys, "references" + assert_includes json.keys, "affected" + assert_includes json.keys, "severity" + assert_includes json.keys, "database_specific" + end + + test "returns correct affected structure" do + get osv_vuln_url("GHSA-xxxx-yyyy-zzzz"), as: :json + assert_response :success + + json = JSON.parse(response.body) + affected = json["affected"] + + assert_equal 1, affected.length + assert_equal "lodash", affected.first["package"]["name"] + assert_equal "npm", affected.first["package"]["ecosystem"] + assert_not_empty affected.first["ranges"] + end + + test "returns correct range events structure" do + get osv_vuln_url("GHSA-xxxx-yyyy-zzzz"), as: :json + assert_response :success + + json = JSON.parse(response.body) + ranges = json["affected"].first["ranges"] + + assert_equal 1, ranges.length + assert_equal "ECOSYSTEM", ranges.first["type"] + + events = ranges.first["events"] + introduced_event = events.find { |e| e.key?("introduced") } + fixed_event = events.find { |e| e.key?("fixed") } + + assert_not_nil introduced_event + assert_equal "1.0.0", introduced_event["introduced"] + assert_not_nil fixed_event + assert_equal "4.17.21", fixed_event["fixed"] + end + + test "returns CVE in aliases" do + get osv_vuln_url("GHSA-xxxx-yyyy-zzzz"), as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_includes json["aliases"], "CVE-2023-12345" + refute_includes json["aliases"], "GHSA-xxxx-yyyy-zzzz" + end + + test "returns severity with CVSS vector" do + get osv_vuln_url("GHSA-xxxx-yyyy-zzzz"), as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal 1, json["severity"].length + assert_equal "CVSS_V3", json["severity"].first["type"] + assert_equal @advisory.cvss_vector, json["severity"].first["score"] + end + + test "handles ID with special characters" do + @advisory.update!(uuid: "MAL-2023-1234") + @advisory.update!(identifiers: ["MAL-2023-1234"]) + + get osv_vuln_url("MAL-2023-1234"), as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_equal "MAL-2023-1234", json["id"] + end + + test "handles timestamps in ISO 8601 format" do + get osv_vuln_url("GHSA-xxxx-yyyy-zzzz"), as: :json + assert_response :success + + json = JSON.parse(response.body) + assert_match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, json["published"]) + assert_match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, json["modified"]) + end +end diff --git a/test/models/osv/query_service_test.rb b/test/models/osv/query_service_test.rb new file mode 100644 index 00000000..27e19163 --- /dev/null +++ b/test/models/osv/query_service_test.rb @@ -0,0 +1,190 @@ +require "test_helper" + +class Osv::QueryServiceTest < ActiveSupport::TestCase + setup do + @source = create(:source) + @npm_advisory = create(:advisory, + source: @source, + uuid: "GHSA-npm-0001", + packages: [ + { "ecosystem" => "npm", "package_name" => "lodash", "versions" => [] } + ] + ) + @pypi_advisory = create(:advisory, + source: @source, + uuid: "GHSA-pypi-0001", + packages: [ + { "ecosystem" => "pypi", "package_name" => "django", "versions" => [] } + ] + ) + @ruby_advisory = create(:advisory, + source: @source, + uuid: "GHSA-ruby-0001", + identifiers: ["GHSA-ruby-0001", "CVE-2023-99999"], + packages: [ + { "ecosystem" => "rubygems", "package_name" => "rails", "versions" => [] } + ] + ) + end + + test "finds advisory by ecosystem and name" do + service = Osv::QueryService.new(package: { ecosystem: "npm", name: "lodash" }) + result = service.find_vulnerabilities + + assert_equal 1, result[:advisories].length + assert_equal "GHSA-npm-0001", result[:advisories].first.uuid + end + + test "finds advisory by OSV ecosystem name (PyPI)" do + service = Osv::QueryService.new(package: { ecosystem: "PyPI", name: "django" }) + result = service.find_vulnerabilities + + assert_equal 1, result[:advisories].length + assert_equal "GHSA-pypi-0001", result[:advisories].first.uuid + end + + test "finds advisory by OSV ecosystem name (RubyGems)" do + service = Osv::QueryService.new(package: { ecosystem: "RubyGems", name: "rails" }) + result = service.find_vulnerabilities + + assert_equal 1, result[:advisories].length + assert_equal "GHSA-ruby-0001", result[:advisories].first.uuid + end + + test "normalizes ecosystem names case-insensitively" do + service = Osv::QueryService.new(package: { ecosystem: "NPM", name: "lodash" }) + result = service.find_vulnerabilities + + assert_equal 1, result[:advisories].length + end + + test "finds advisory by PURL" do + service = Osv::QueryService.new(purl: "pkg:npm/lodash@4.17.21") + result = service.find_vulnerabilities + + assert_equal 1, result[:advisories].length + assert_equal "GHSA-npm-0001", result[:advisories].first.uuid + end + + test "finds advisory by PURL without version" do + service = Osv::QueryService.new(purl: "pkg:npm/lodash") + result = service.find_vulnerabilities + + assert_equal 1, result[:advisories].length + end + + test "finds advisory by gem PURL" do + service = Osv::QueryService.new(purl: "pkg:gem/rails") + result = service.find_vulnerabilities + + assert_equal 1, result[:advisories].length + assert_equal "GHSA-ruby-0001", result[:advisories].first.uuid + end + + test "raises error when version in both purl and package" do + service = Osv::QueryService.new(purl: "pkg:npm/lodash@4.17.21", package: { version: "4.17.20" }) + + assert_raises ArgumentError do + service.find_vulnerabilities + end + end + + test "returns empty results for unknown package" do + service = Osv::QueryService.new(package: { ecosystem: "npm", name: "nonexistent" }) + result = service.find_vulnerabilities + + assert_equal 0, result[:advisories].length + assert_nil result[:next_page_token] + end + + test "returns empty results for invalid PURL" do + service = Osv::QueryService.new(purl: "invalid-purl") + result = service.find_vulnerabilities + + assert_equal 0, result[:advisories].length + end + + test "paginates results" do + # Create more advisories to test pagination + 102.times do |i| + create(:advisory, + source: @source, + uuid: "GHSA-page-#{i.to_s.rjust(4, '0')}", + packages: [{ "ecosystem" => "npm", "package_name" => "test-pagination", "versions" => [] }] + ) + end + + service = Osv::QueryService.new(package: { ecosystem: "npm", name: "test-pagination" }) + result = service.find_vulnerabilities + + assert_equal 100, result[:advisories].length + assert_not_nil result[:next_page_token] + + # Request second page + service2 = Osv::QueryService.new( + package: { ecosystem: "npm", name: "test-pagination" }, + page_token: result[:next_page_token] + ) + result2 = service2.find_vulnerabilities + + assert_equal 2, result2[:advisories].length + assert_nil result2[:next_page_token] + end + + test "encodes page token correctly" do + service = Osv::QueryService.new({}) + token = service.encode_page_token(100) + + assert_not_nil token + assert_equal 100, service.decode_page_token(token) + end + + test "decodes invalid page token as 0" do + service = Osv::QueryService.new({}) + assert_equal 0, service.decode_page_token("invalid!!!") + end + + test "decodes nil page token as 0" do + service = Osv::QueryService.new({}) + assert_equal 0, service.decode_page_token(nil) + end + + test "find_by_id finds by uuid" do + advisory = Osv::QueryService.find_by_id("GHSA-npm-0001") + + assert_not_nil advisory + assert_equal "GHSA-npm-0001", advisory.uuid + end + + test "find_by_id finds by CVE identifier" do + advisory = Osv::QueryService.find_by_id("CVE-2023-99999") + + assert_not_nil advisory + assert_equal "GHSA-ruby-0001", advisory.uuid + end + + test "find_by_id returns nil for unknown id" do + advisory = Osv::QueryService.find_by_id("GHSA-nonexistent") + + assert_nil advisory + end + + test "returns all advisories when no filters provided" do + service = Osv::QueryService.new({}) + result = service.find_vulnerabilities + + assert_equal 3, result[:advisories].length + end + + test "orders results by updated_at desc" do + @npm_advisory.update_column(:updated_at, 1.day.ago) + @pypi_advisory.update_column(:updated_at, 2.days.ago) + @ruby_advisory.update_column(:updated_at, 1.hour.ago) + + service = Osv::QueryService.new({}) + result = service.find_vulnerabilities + + assert_equal "GHSA-ruby-0001", result[:advisories].first.uuid + assert_equal "GHSA-pypi-0001", result[:advisories].last.uuid + end +end diff --git a/test/models/osv/vulnerability_transformer_test.rb b/test/models/osv/vulnerability_transformer_test.rb new file mode 100644 index 00000000..cf0f3c56 --- /dev/null +++ b/test/models/osv/vulnerability_transformer_test.rb @@ -0,0 +1,280 @@ +require "test_helper" + +class Osv::VulnerabilityTransformerTest < ActiveSupport::TestCase + setup do + @source = create(:source) + @advisory = create(:advisory, + source: @source, + uuid: "GHSA-xxxx-yyyy-zzzz", + title: "Test Vulnerability", + description: "A test vulnerability description", + identifiers: ["GHSA-xxxx-yyyy-zzzz", "CVE-2023-12345"], + cvss_vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + cvss_score: 9.8, + severity: "critical", + references: ["https://github.com/example/advisory", "https://nvd.nist.gov/vuln/detail/CVE-2023-12345"], + packages: [ + { + "ecosystem" => "npm", + "package_name" => "lodash", + "versions" => [ + { "vulnerable_version_range" => ">= 1.0.0, < 4.17.21", "first_patched_version" => "4.17.21" } + ] + } + ], + published_at: Time.zone.parse("2023-01-15 10:00:00 UTC"), + withdrawn_at: nil + ) + end + + test "transforms advisory to full OSV format" do + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal "GHSA-xxxx-yyyy-zzzz", result[:id] + assert_equal "Test Vulnerability", result[:summary] + assert_equal "A test vulnerability description", result[:details] + assert_includes result[:aliases], "CVE-2023-12345" + refute_includes result[:aliases], "GHSA-xxxx-yyyy-zzzz" + assert_not_nil result[:modified] + assert_not_nil result[:published] + refute_includes result.keys, :withdrawn + end + + test "transforms advisory to summary format" do + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform(summary_only: true) + + assert_equal "GHSA-xxxx-yyyy-zzzz", result[:id] + assert_not_nil result[:modified] + assert_equal 2, result.keys.count + end + + test "truncates long titles to 120 characters" do + @advisory.update!(title: "A" * 200) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal 120, result[:summary].length + assert result[:summary].end_with?("...") + end + + test "maps npm ecosystem correctly" do + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal "npm", result[:affected].first[:package][:ecosystem] + end + + test "maps pypi ecosystem to PyPI" do + @advisory.update!(packages: [{ "ecosystem" => "pypi", "package_name" => "django", "versions" => [] }]) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal "PyPI", result[:affected].first[:package][:ecosystem] + end + + test "maps rubygems ecosystem to RubyGems" do + @advisory.update!(packages: [{ "ecosystem" => "rubygems", "package_name" => "rails", "versions" => [] }]) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal "RubyGems", result[:affected].first[:package][:ecosystem] + end + + test "maps go ecosystem correctly" do + @advisory.update!(packages: [{ "ecosystem" => "go", "package_name" => "golang.org/x/net", "versions" => [] }]) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal "Go", result[:affected].first[:package][:ecosystem] + end + + test "maps cargo ecosystem to crates.io" do + @advisory.update!(packages: [{ "ecosystem" => "cargo", "package_name" => "serde", "versions" => [] }]) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal "crates.io", result[:affected].first[:package][:ecosystem] + end + + test "maps maven ecosystem to Maven" do + @advisory.update!(packages: [{ "ecosystem" => "maven", "package_name" => "org.apache.log4j:log4j-core", "versions" => [] }]) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal "Maven", result[:affected].first[:package][:ecosystem] + end + + test "maps nuget ecosystem to NuGet" do + @advisory.update!(packages: [{ "ecosystem" => "nuget", "package_name" => "Newtonsoft.Json", "versions" => [] }]) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal "NuGet", result[:affected].first[:package][:ecosystem] + end + + test "includes CVSS v3 severity" do + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal 1, result[:severity].length + assert_equal "CVSS_V3", result[:severity].first[:type] + assert_equal @advisory.cvss_vector, result[:severity].first[:score] + end + + test "detects CVSS v4 vectors" do + @advisory.update!(cvss_vector: "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N") + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal "CVSS_V4", result[:severity].first[:type] + end + + test "excludes severity when cvss_vector is nil" do + @advisory.update!(cvss_vector: nil) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + refute_includes result.keys, :severity + end + + test "includes withdrawn timestamp when present" do + @advisory.update!(withdrawn_at: Time.zone.parse("2023-06-01 12:00:00 UTC")) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_includes result.keys, :withdrawn + assert_equal "2023-06-01T12:00:00Z", result[:withdrawn] + end + + test "parses version range with >= and <" do + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + ranges = result[:affected].first[:ranges] + assert_equal 1, ranges.length + assert_equal "ECOSYSTEM", ranges.first[:type] + + events = ranges.first[:events] + assert events.any? { |e| e[:introduced] == "1.0.0" } + assert events.any? { |e| e[:fixed] == "4.17.21" } + end + + test "parses simple < version range" do + @advisory.update!(packages: [ + { + "ecosystem" => "npm", + "package_name" => "test", + "versions" => [{ "vulnerable_version_range" => "< 2.0.0" }] + } + ]) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + events = result[:affected].first[:ranges].first[:events] + assert events.any? { |e| e[:introduced] == "0" } + assert events.any? { |e| e[:fixed] == "2.0.0" } + end + + test "parses >= only version range" do + @advisory.update!(packages: [ + { + "ecosystem" => "npm", + "package_name" => "test", + "versions" => [{ "vulnerable_version_range" => ">= 1.0.0" }] + } + ]) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + events = result[:affected].first[:ranges].first[:events] + assert events.any? { |e| e[:introduced] == "1.0.0" } + refute events.any? { |e| e.key?(:fixed) } + end + + test "uses first_patched_version when no fixed in range" do + @advisory.update!(packages: [ + { + "ecosystem" => "npm", + "package_name" => "test", + "versions" => [{ "vulnerable_version_range" => ">= 1.0.0", "first_patched_version" => "1.5.0" }] + } + ]) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + events = result[:affected].first[:ranges].first[:events] + assert events.any? { |e| e[:fixed] == "1.5.0" } + end + + test "builds references with inferred types" do + @advisory.update!(references: [ + "https://github.com/example/advisory", + "https://nvd.nist.gov/vuln/detail/CVE-2023-12345", + "https://github.com/example/repo/commit/abc123", + "https://example.com/info" + ]) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + refs = result[:references] + assert_equal 4, refs.length + assert refs.any? { |r| r[:type] == "ADVISORY" && r[:url].include?("advisory") } + assert refs.any? { |r| r[:type] == "ADVISORY" && r[:url].include?("nvd.nist.gov") } + assert refs.any? { |r| r[:type] == "FIX" && r[:url].include?("commit") } + assert refs.any? { |r| r[:type] == "WEB" && r[:url] == "https://example.com/info" } + end + + test "builds database_specific with extra fields" do + @advisory.update!(url: "https://example.com/advisory/123", source_kind: "github") + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + db_specific = result[:database_specific] + assert_equal "github", db_specific[:source] + assert_equal "https://example.com/advisory/123", db_specific[:url] + assert_equal @advisory.severity, db_specific[:severity] + assert_equal @advisory.cvss_score, db_specific[:cvss_score] + end + + test "excludes uuid from aliases" do + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + refute_includes result[:aliases], @advisory.uuid + assert_includes result[:aliases], "CVE-2023-12345" + end + + test "handles advisory with no packages" do + @advisory.update!(packages: []) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal [], result[:affected] + end + + test "handles advisory with empty versions" do + @advisory.update!(packages: [{ "ecosystem" => "npm", "package_name" => "test", "versions" => [] }]) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal [], result[:affected].first[:ranges] + end + + test "formats timestamps in ISO 8601 UTC" do + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, result[:published]) + assert_match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, result[:modified]) + end + + test "preserves unknown ecosystem names" do + @advisory.update!(packages: [{ "ecosystem" => "unknown_ecosystem", "package_name" => "test", "versions" => [] }]) + transformer = Osv::VulnerabilityTransformer.new(@advisory) + result = transformer.transform + + assert_equal "unknown_ecosystem", result[:affected].first[:package][:ecosystem] + end +end From 6cebe8afa4184db51e7cf70116b2526040620428 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 20 Jan 2026 14:01:27 +0000 Subject: [PATCH 2/2] Add OSV as an advisory source - Add Sources::Osv class to sync advisories from Google OSV database - Fetch advisories by ecosystem from OSV's GCS bucket - Map OSV format to Advisory model with CVSS scoring via cvss_suite - Add source icons for different advisory sources (GitHub, OSV, erlef) - Add source_icon helper for displaying source icons in views - Update advisory views to show source icons - Add zip gem for extracting OSV data bundles --- Gemfile | 1 + Gemfile.lock | 3 + app/controllers/advisories_controller.rb | 5 +- app/controllers/ecosystems_controller.rb | 4 +- app/helpers/application_helper.rb | 8 + app/models/advisory.rb | 1 + app/models/source.rb | 10 + app/models/sources/osv.rb | 337 +++++++++++++++ app/views/advisories/_advisory.html.erb | 8 +- app/views/advisories/index.html.erb | 12 +- app/views/advisories/show.html.erb | 10 +- db/seeds.rb | 1 + lib/tasks/advisories.rake | 10 + test/models/sources/osv_test.rb | 508 +++++++++++++++++++++++ 14 files changed, 908 insertions(+), 10 deletions(-) create mode 100644 app/models/sources/osv.rb create mode 100644 test/models/sources/osv_test.rb diff --git a/Gemfile b/Gemfile index 7b0dbbc7..3b5cc9ea 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 28087178..d08abe83 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/app/controllers/advisories_controller.rb b/app/controllers/advisories_controller.rb index 0586c1ed..c2a70ebb 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 74bdb8e7..ef2a2736 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/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2250ae0a..59c46789 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 464649d9..0157173e 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/source.rb b/app/models/source.rb index 20ec368a..94c604d2 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 00000000..4dc9a482 --- /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 b90b8ebb..361b8902 100644 --- a/app/views/advisories/_advisory.html.erb +++ b/app/views/advisories/_advisory.html.erb @@ -20,15 +20,17 @@
<% if advisory.packages.any? %> + <% ecosystems = advisory.ecosystems %> + <% package_names = advisory.package_names %> <%= bootstrap_icon 'boxes', width: 18, height: 18, class: 'flex-shrink-0 me-2' %> - <%= advisory.ecosystems.join(', ') %> + <%= ecosystems.first(3).join(', ') %><%= " +#{ecosystems.size - 3} more" if ecosystems.size > 3 %> <%= bootstrap_icon 'box', width: 18, height: 18, class: 'flex-shrink-0 me-2' %> - <%= advisory.package_names.join(', ') %> + <%= package_names.first(5).join(', ') %><%= " +#{package_names.size - 5} more" if package_names.size > 5 %> <% elsif advisory.repository_url.present? %> <%= bootstrap_icon 'git', width: 18, height: 18, class: 'flex-shrink-0 me-2' %> <%= advisory.repository_full_name %> <% else %> - <%= bootstrap_icon 'shield-exclamation', width: 18, height: 18, class: 'flex-shrink-0 me-2' %> + <%= source_icon(advisory.source, class: 'flex-shrink-0 me-2') %> <%= advisory.source.name %> <% end %>
diff --git a/app/views/advisories/index.html.erb b/app/views/advisories/index.html.erb index 7e6bf8fa..d56271ad 100644 --- a/app/views/advisories/index.html.erb +++ b/app/views/advisories/index.html.erb @@ -57,7 +57,17 @@ + +

Filter by Classification

+
+ <% @classifications.each do |classification, count| %> + + <%= classification.humanize %> <%= number_with_delimiter count %> <% end %> diff --git a/app/views/advisories/show.html.erb b/app/views/advisories/show.html.erb index c1359b52..66caeee1 100644 --- a/app/views/advisories/show.html.erb +++ b/app/views/advisories/show.html.erb @@ -15,7 +15,9 @@
- <%= @advisory.severity.humanize %> + <% if @advisory.severity.present? %> + <%= @advisory.severity.humanize %> + <% end %> <% if @advisory.cvss_score && @advisory.cvss_score > 0 %> CVSS: <%= @advisory.cvss_score %> <% end %> @@ -153,8 +155,10 @@

<%= identifier %>

<% end %>

Risk

-

Severity

-

<%= @advisory.severity.humanize %>

+ <% if @advisory.severity.present? %> +

Severity

+

<%= @advisory.severity.humanize %>

+ <% end %>