kev.cr
kev
A Crystal implementation of the CISA Known Exploited Vulnerabilities (KEV) catalog — parsing, querying, fetching, and JSON serialization for the official feed.
- Strict, schema-bound parser modeled on the official KEV JSON schema — validates
cveID/CWE patterns at parse time - Type-safe
VulnerabilityandCatalogmodels - Chainable
Querybuilder for vendor / product / CWE / ransomware / due-date filters - Built-in HTTP
ClientwithETag/If-Modified-Sincesupport, optional redirect-following, and URL-rich error messages - Lossless JSON round-trips against the canonical CISA feed — including unknown-but-valid
knownRansomwareCampaignUsestrings preserved onknown_ransomware_campaign_use_raw - CSV mirror support via
Catalog.parse_csv/Client.fetch_csvand CSV export viaCatalog#to_csv - Explicit
validate!/valid?for re-running schema checks on programmatically constructed records - Convenience surface:
Catalog#search,#diff,#stats,#group_by_*,#latest/#oldest, and per-entry deep links (#cisa_url/#nvd_url/#mitre_url)
Installation
Add the dependency to your shard.yml:
dependencies:
kev:
github: hahwul/kev.cr
Then shards install.
Usage
Parse a catalog
require "kev"
catalog = KEV.parse(File.read("known_exploited_vulnerabilities.json"))
catalog.size # => 1592
catalog.catalog_version # => "2026.05.15"
catalog["CVE-2021-44228"].vendor_project # => "Apache"
Fetch the live CISA feed
catalog = KEV.fetch
puts "#{catalog.size} entries, released #{catalog.date_released}"
# Long-lived client with conditional GETs:
client = KEV::Client.new
first = client.fetch
later = client.fetch_if_modified # => nil when the feed is unchanged
# CSV mirror — same per-row data, no catalog metadata:
csv_catalog = KEV::Client.fetch_csv
# Follow redirects (off by default — set when pointing at a mirror):
KEV::Client.new(url: "https://example.com/kev.json", max_redirects: 3).fetch
Snapshot diff and summary
old = KEV::Catalog.parse(File.read("kev-yesterday.json"))
new = KEV.fetch
delta = old.diff(new)
delta.added.each { |v| puts "added: #{v.summary}" }
delta.removed.each { |v| puts "removed: #{v.cve_id}" }
delta.changed.each { |before, after| puts "edited: #{after.cve_id}" }
# One-call rollup — totals, ransomware, overdue, top vendors / CWEs / years.
puts new.stats(top: 5)
# => #<KEV::Stats total=1592 ransomware=321 overdue=1590 years=12 top_vendor=Microsoft(371)>
Search and group
catalog.search("log4j") # substring across cve_id, name, description, vendor, product
catalog.latest(10) # newest first
catalog.oldest(5) # earliest first
catalog.group_by_year[2024] # all 2024-numbered CVEs
catalog.group_by_vendor["Microsoft"]
catalog.group_by_cwe["CWE-79"]
catalog.group_by_ransomware[KEV::RansomwareUse::Known]
Per-entry deep links
v = catalog["CVE-2021-44228"]
v.cisa_url # CISA catalog page filtered to this CVE
v.nvd_url # NVD detail page
v.mitre_url # MITRE CVE record
v.summary # one-line digest with [ransomware]/[overdue] flags
Export to CSV
File.write("kev.csv", catalog.to_csv)
KEV::Catalog.parse_csv(File.read("kev.csv")) # round-trips
Validate programmatic records
from_json enforces every schema-level constraint, but constructor and in-place edits bypass that path. validate! / valid? re-run the schema checks on demand:
catalog.validate! # raises KEV::InvalidValueError on the first bad entry
catalog.valid? # => true / false
vuln.validate! # same, scoped to one entry
Look up and filter
catalog.find("CVE-2021-44228") # => KEV::Vulnerability | nil
catalog["CVE-2021-44228"] # => raises KeyError on miss
catalog["CVE-2021-44228"]? # => same as find
catalog.by_vendor("Microsoft") # case-insensitive
catalog.by_cwe("CWE-79") # or just "79"
catalog.ransomware # Array(Vulnerability)
catalog.overdue # past their due date
catalog.due_within(30.days)
Chainable queries
catalog.query
.vendor("Microsoft")
.ransomware
.added_on_or_after(Time.utc(2024, 1, 1))
.sort_by_due_date
.to_a
Other Query filters: product, name_matches, description_matches, cwe, non_ransomware, year, due_on_or_after, due_on_or_before, overdue, due_within, and a generic where { |v| ... } escape hatch.
Vulnerability predicates
v = catalog["CVE-2021-44228"]
v.known_ransomware? # => true
v.overdue? # => true (relative to now)
v.days_until_due # => negative when overdue
v.remediation_window_days # => 14
v.has_cwe?("CWE-917") # => true
v.cve_year # => 2021
Equality, ordering, sets
Vulnerability equality is keyed by cve_id, so deduping across feed snapshots is straightforward:
seen = Set(KEV::Vulnerability).new
catalog.each { |v| seen << v }
Vectors sort by date_added (with cve_id as a stable tiebreak), and Catalog is Enumerable + Indexable, so all the usual collection methods work directly.
JSON serialization
Vulnerability#to_json and Catalog#to_json emit output that matches the canonical CISA feed shape — field names and order are preserved, and the result round-trips through KEV::Catalog.parse. Empty optional strings (notes: "") and unknown-but-valid knownRansomwareCampaignUse values are preserved verbatim so byte-level diffs against the upstream feed do not show spurious deltas.
require "json"
catalog = KEV.parse(File.read("kev.json"))
File.write("kev_filtered.json", catalog.query.ransomware.to_a.to_json)
reparsed = KEV::Catalog.parse(catalog.to_json)
reparsed.size == catalog.size # => true
Errors
All exceptions inherit from KEV::Error:
KEV::ParseError— malformed JSON, missing fields, bad dates.KEV::MissingFieldError < ParseError— a schema-required field is absent.KEV::InvalidValueError < ParseError— a field value violates a schema-level pattern (e.g. a malformedcveIDor a CWE that does not match^CWE-[0-9]+$).KEV::FetchError— transport-level failures inKEV::Client.
KEV.parse? and KEV::Catalog.parse? return nil instead of raising.
Development
crystal spec
License
MIT. See LICENSE.
The KEV catalog itself is published by the U.S. Cybersecurity and Infrastructure Security Agency (CISA) and is in the public domain.
Contributors
- hahwul — creator and maintainer
kev.cr
- 1
- 0
- 0
- 0
- 0
- about 3 hours ago
- May 19, 2026
MIT License
Tue, 19 May 2026 15:25:15 GMT