epss.cr
epss.cr
A Crystal implementation of the Exploit Prediction Scoring System (EPSS) from FIRST.
epss.cr covers the two ways EPSS is consumed in practice:
- The FIRST REST API at
https://api.first.org/data/v1/epss— lookup by CVE, date range, threshold, percentile, with transparent pagination. - The daily CSV feed at
https://epss.empiricalsecurity.com/epss_scores-YYYY-MM-DD.csv.gz— streamed row-by-row, gzip auto-detected. The legacyepss.cyentia.comhost still mirrors the same file and can be supplied viahost:.
It is shaped after cvss.cr: typed value objects, structural equality, JSON round-trip, and a stable error hierarchy.
Installation
Add to your shard.yml:
dependencies:
epss:
github: hahwul/epss.cr
Run shards install.
Usage
require "epss"
Look up one CVE
if score = EPSS.score("CVE-2022-27225")
puts "EPSS=#{score.epss} percentile=#{score.percentile} band=#{score.band}"
end
# Pluck just one field
EPSS.band("CVE-2022-27225") # => EPSS::Band::Low
EPSS.epss("CVE-2022-27225") # => 0.00187
EPSS.percentile("CVE-2022-27225") # => 0.40129
One-liner queries
EPSS.top(10) # 10 highest-EPSS CVEs
EPSS.above(0.95) # every CVE above the threshold (paginated)
EPSS.search("openssl") # free-text search, EPSS-desc
EPSS.feed(Time.utc(2026, 5, 18)) # daily CSV feed
EPSS.today_feed # today's CSV feed
Same builders are available on EPSS::Query for composition:
EPSS::Query.top(50).with_percentile_gt(0.9)
EPSS::Query.search("kernel").with_limit(25)
EPSS::Query.above(0.5).with_order("!epss")
EPSS::Query.for_cve("CVE-2022-27225")
EPSS::Query.recent(7)
Batch lookup
EPSS.scores(["CVE-2024-1", "CVE-2024-2", "CVE-2024-3"]).each do |s|
puts s
end
Threshold filter with pagination
client = EPSS::Client.new
client.each_score(EPSS::Query.new(epss_gt: 0.95, order: "!epss")) do |score|
puts "#{score.cve}\t#{score.epss}"
end
Parse the daily CSV feed
File.open("epss_scores-2026-05-18.csv.gz") do |io|
EPSS::CSV.each_score(io) do |score|
db.upsert(score.cve, score.epss, score.percentile)
end
end
# Or load it all at once and inspect the file metadata:
feed = EPSS::CSV.parse(File.read("epss_scores-2026-05-18.csv.gz"))
feed.metadata.model_version # => "v2025.03.14"
feed.metadata.score_date # => Time(2026-05-18)
feed.scores.size # => 240000+
Download a daily feed by date
EPSS::CSV.feed_url(Time.utc(2026, 5, 18))
# => URI("https://epss.empiricalsecurity.com/epss_scores-2026-05-18.csv.gz")
# Routed through the same retry/timeout/User-Agent pipeline as the JSON
# client — slow networks surface as EPSS::APIError rather than hangs.
feed = EPSS::CSV.fetch(Time.utc(2026, 5, 18))
feed.metadata.score_date # => Time(2026-05-18)
Field projection
Request a partial payload via the FIRST API's fields parameter to skip percentile/date when the caller doesn't need them:
query = EPSS::Query.new
.with_epss_gt(0.95)
.with_order("!epss")
.with_fields(["cve", "epss"])
Query also exposes with_pretty(true) and with_envelope(false) for the other FIRST global parameters.
JSON round-trip
# Single Score
score = EPSS::Score.new("CVE-2022-27225", 0.001870, 0.401290, Time.utc(2026, 5, 18))
EPSS::Score.from_json(score.to_json).should eq(score)
# Full API envelope
resp = client.fetch(EPSS::Query.new(cves: ["CVE-2022-27225"]))
captured = resp.to_json
EPSS::Response.from_json(captured) # round-trips
Bands
EPSS::Band provides a qualitative bucket — useful for surfacing rows in dashboards or filtering noisy low-probability CVEs.
EPSS::Band.from_epss(0.92) # => EPSS::Band::Critical
EPSS::Band.from_percentile(0.85) # => EPSS::Band::Medium
EPSS::Band.parse("critical") # => EPSS::Band::Critical
score.band # uses the EPSS probability cutoffs
score.percentile_band # uses the percentile cutoffs
score.critical? # band predicates: none?/low?/medium?/high?/critical?
score.at_least?(:high) # threshold check (default :high)
Score helpers
score.percentage # => 42.0 (epss * 100, display-friendly)
score.percentile_percentage # => 99.0
score.age # Time::Span since the score was published
older = EPSS.score("CVE-1", date: Time.utc - 7.days)
score.delta(older) # change in EPSS probability since last week
| Band | EPSS probability | Percentile rank |
|---|---|---|
| None | < 0.01 |
< 0.50 |
| Low | [0.01, 0.10) |
[0.50, 0.80) |
| Medium | [0.10, 0.30) |
[0.80, 0.90) |
| High | [0.30, 0.70) |
[0.90, 0.99) |
| Critical | >= 0.70 |
>= 0.99 |
The cutoffs are conventions — EPSS itself ships only numeric values. Pick whichever set fits the policy you're enforcing.
Configuring the client
client = EPSS::Client.new(
base_uri: URI.parse("https://api.first.org/data/v1/epss"),
user_agent: "myapp/1.0",
max_retries: 5,
retry_backoff: 1.second,
)
For tests, inject a custom EPSS::Transport:
class FakeTransport < EPSS::Transport
def get(uri : URI, headers : HTTP::Headers) : HTTP::Client::Response
HTTP::Client::Response.new(200, body: my_fixture)
end
end
client = EPSS::Client.new(transport: FakeTransport.new)
Error handling
All errors descend from EPSS::Error:
EPSS::ParseError— malformed JSON / CSV / constructor argument.EPSS::APIError— HTTP non-2xx, orstatus != "OK"in the envelope. Carries the responsestatusandbody.
Use EPSS.from_json? for the non-raising form.
Development
crystal spec # run the test suite
crystal build src/epss.cr --no-codegen # type-check
The HTTP-touching code is fully driven through an injectable EPSS::Transport; specs use a StubTransport (see spec/spec_helper.cr) and never hit the network.
Contributing
- Fork it (https://github.com/hahwul/epss.cr/fork)
- Create your feature branch (
git checkout -b feat/your-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin feat/your-feature) - Create a new Pull Request
License
MIT. See LICENSE.
epss.cr
- 1
- 0
- 0
- 0
- 0
- about 3 hours ago
- May 19, 2026
MIT License
Tue, 19 May 2026 15:25:18 GMT