class

KEV::Client

Inherits Reference < Object

HTTP client for the CISA KEV feed.

The default endpoint is the canonical JSON feed: https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json

catalog = KEV::Client.fetch
puts catalog.size

For repeated polling (e.g. a CI job), prefer a long-lived instance and use fetch_if_modified so unchanged feeds short-circuit without a full download:

client = KEV::Client.new
if catalog = client.fetch_if_modified
  process(catalog)
end

Constants

DEFAULT_CSV_URL = "https://www.cisa.gov/sites/default/files/csv/known_exploited_vulnerabilities.csv"

CISA's CSV mirror of the same catalog. Same per-row data, no catalog-level metadata (catalogVersion, dateReleased, count).

DEFAULT_MAX_REDIRECTS = 0

Default ceiling for redirect chasing in fetch. Most KEV requests answer 200 directly, so the default is 0: no redirects are followed and a 3xx response raises FetchError. Raise this (via the max_redirects argument) when pointing the client at a mirror or proxy that issues a canonical redirect.

DEFAULT_MAX_RETRIES = 3

Default number of retries (in addition to the initial attempt) for transient failures. A single CISA hiccup — a 503 behind their CDN, a reset connection, a read timeout — should not surface to the caller, so the request is retried with exponential backoff before giving up.

DEFAULT_RETRY_BACKOFF = 500.milliseconds

Base delay for the first backoff sleep. Subsequent retries double this (capped at MAX_BACKOFF) and add jitter.

DEFAULT_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"

CISA's published JSON feed URL — the schema-bound source of truth.

DEFAULT_USER_AGENT = "kev.cr/#{VERSION} (+https://github.com/hahwul/kev.cr)"

User-Agent sent on every request. Identifies the client so CISA can contact maintainers if the feed format changes — and so this library is not silently lumped in with anonymous scrapers.

FOLLOWABLE_REDIRECTS = {301, 302, 303, 307, 308}

Status codes that carry a Location the client is expected to follow — the same set HTTP::Client itself chases.

Deliberately not the whole 3xx range: HTTP::Status#redirection? answers true for 304 Not Modified (and for 305/306), none of which carry a Location. Testing that predicate made a plain fetch against a caching intermediary report "redirect … missing Location header" instead of the actual status.

MAX_BACKOFF = 30.seconds

Upper bound for a single exponential-backoff sleep. Without a cap the doubling delay grows unbounded and can strand a fiber for minutes; 30s is plenty to let a transient outage clear.

RETRIABLE_STATUS = {429, 500, 502, 503, 504}

HTTP status codes worth retrying: rate-limiting plus the transient gateway/server-side 5xx family. A 4xx (404, 403, …) is the caller's problem and is never retried.

Constructors

new(url : String = DEFAULT_URL, user_agent : String = DEFAULT_USER_AGENT, connect_timeout : Time::Span = 10.seconds, read_timeout : Time::Span = 30.seconds, max_redirects : Int32 = DEFAULT_MAX_REDIRECTS, max_retries : Int32 = DEFAULT_MAX_RETRIES, retry_backoff : Time::Span = DEFAULT_RETRY_BACKOFF)
Source

Class methods

fetch(url : String = DEFAULT_URL) : Catalog

Convenience: one-shot fetch using a fresh client.

Source
fetch_csv(url : String = DEFAULT_CSV_URL) : Catalog

One-shot CSV fetch using a fresh client pointed at the CSV mirror.

Source

Instance methods

backoff_delay(attempt : Int32) : Time::Span

Exponential backoff with a hard cap and decorrelated jitter: base * 2^(attempt-1), clamped to MAX_BACKOFF, plus up to 10% jitter.

The cap keeps a long retry chain from sleeping for minutes, and the jitter spreads concurrent retries so they don't all wake at once. This is library runtime code (not a deterministic workflow), so a random source is acceptable here.

Public so the cap/jitter bounds can be unit-tested without sleeping.

Source
connect_timeout
Source
fetch

Fetch and parse the catalog. Raises FetchError on any transport or HTTP-level failure (including a non-2xx response — see below) and KEV::ParseError on a schema-malformed body. JSON::ParseException propagates unchanged for raw JSON syntax errors, matching the cvss.cr precedent.

NOTE: redirects are not followed by default (max_redirects is 0). CISA's canonical feed URL has been stable, so a 3xx raises FetchError rather than silently following to a possibly untrusted host — resolve the final URL yourself and pass it to initialize, or raise max_redirects if you are pointing at a mirror or proxy that issues a canonical redirect.

Source
fetch_csv(catalog_version : String = "csv", date_released : Time = Time.utc, title : String | Nil = nil) : Catalog

Fetch the CSV form of the catalog from the configured url. CSV has no catalog metadata, so the synthesized catalog_version and date_released arguments flow through to Catalog.parse_csv.

Source
fetch_if_modified

Conditional fetch using If-None-Match / If-Modified-Since. Returns the new catalog on a 200 response and nil on a 304.

First call (no validators recorded yet) behaves like fetch.

A 304 still refreshes last_etag / last_modified when the server sends updated ones, so a long-lived poller tracks validator rotation instead of pinning the first pair it ever saw.

Source
last_etag

Last ETag observed on a successful fetch (or nil if the server did not return one). Used by fetch_if_modified.

Source
last_modified

Last Last-Modified header observed on a successful fetch.

Source
max_redirects

Maximum number of HTTP redirects to follow. 0 (the default) keeps the original strict behavior — a 3xx response raises FetchError. Set this when pointing the client at a mirror or proxy that issues a canonical redirect.

Source
max_retries

Number of retries (beyond the first attempt) for transient failures. 0 restores the original single-attempt behavior.

Source
read_timeout
Source
retry_backoff

Base backoff delay; doubled per retry, capped at MAX_BACKOFF, plus jitter. Injectable so tests can drive the retry path with a near-zero delay and stay fast.

Source
user_agent
Source