ESC

JSON & Filters

NVD-shaped JSON serialization and classification helpers for filtering

NVD-shaped JSON output

Vector#to_json produces a payload aligned with the FIRST CVSS JSON Schema and the NVD CVE feed format:

require "json"

vec = CVSS.parse("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/E:F/RL:O/RC:C")
puts vec.to_json
{
  "version": "3.1",
  "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/E:F/RL:O/RC:C",
  "baseScore": 9.8,
  "baseSeverity": "CRITICAL",
  "exploitabilityScore": 3.9,
  "impactScore": 5.9,
  "temporalScore": 9.1,
  "temporalSeverity": "CRITICAL"
}

Per-version differences:

Reading JSON

CVSS.from_json accepts:

CVSS.from_json(%({"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}))
# => CVSS::V3::Vector

CVSS.from_json(File.read("nvd_response.json"))

The two flat shapes are a lookup — the caller pointed at the vector, so a malformed one there is an error. Searching the rest of a document is not, so entries the search cannot use (a placeholder "", a null, a CVSS version this library does not implement) are skipped rather than allowed to hide a real vector sitting beside them. Only a scalar or null payload is rejected outright, since it has no depth to search.

A record commonly scores one CVE under several CVSS versions at once. CVSS.from_json returns the first usable vector in document order; CVSS.from_json_all returns every one of them:

vectors = CVSS.from_json_all(File.read("nvd_response.json"))
vectors.map(&.version)        # => ["3.1", "2.0"]
vectors.max_by(&.base_score)  # worst score across versions

from_json_all raises like parse if any vectorString in the payload is malformed — a caller asking for all the vectors cannot be handed a quietly short list — and returns an empty array when the payload holds none at all.

CVSS.from_json? is the non-raising form. It returns nil for everything from_json rejects, including the JSON::ParseException raised when the input is not JSON at all:

if vec = CVSS.from_json?(untrusted_payload)
  # use vec
end

The library only trusts the vectorString — baseScore and similar fields in the input are ignored, and scores are recomputed from the parsed vector. This makes parsing safe against tampered or stale payloads.

Round-tripping via JSON

original    = CVSS.parse("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/E:F")
reconstruct = CVSS.from_json(original.to_json)
reconstruct == original  # => true

Classification helpers

Predicate methods make filtering large vulnerability lists ergonomic:

vec.network?                   # AV:N
vec.adjacent_network?          # AV:A
vec.local?                     # AV:L
vec.physical?                  # AV:P    (v3, v4)
vec.requires_privileges?       # PR != N (v3, v4)
vec.requires_authentication?   # Au != N (v2), Au:R (v1)
vec.remote?                    # AV:R    (v1 only; network? is an alias)
vec.requires_user_interaction? # UI != N
vec.scope_changed?             # S:C    (v3 only)
vec.impacts_subsequent_system? # any of SC/SI/SA != N (v4 only)
vec.impacts_confidentiality?
vec.impacts_integrity?
vec.impacts_availability?

Severity predicates come for free from the Severity enum:

vec.severity.critical?
vec.severity >= CVSS::Severity::High

Filtering example

vulns = inputs.map { |s| CVSS.parse(s) }

# All network-reachable, no-privilege issues sorted worst-first
high_risk = vulns
  .select { |v| v.responds_to?(:network?) && v.network? }
  .reject(&.requires_privileges?)
  .sort
  .reverse

# Anything that crosses scope or hits the subsequent system
crossing = vulns.select do |v|
  (v.is_a?(CVSS::V3::Vector) && v.scope_changed?) ||
    (v.is_a?(CVSS::V4::Vector) && v.impacts_subsequent_system?)
end