Skip to content

Commit 482abaf

Browse files
committed
kev: CISA KEV enricher
Signed-off-by: RTann <[email protected]> rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED
1 parent f9c108f commit 482abaf

File tree

4 files changed

+780
-0
lines changed

4 files changed

+780
-0
lines changed

enricher/kev/kev.go

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
// Package kev provides a CISA Known Exploited Vulnerabilities enricher.
2+
package kev
3+
4+
import (
5+
"bufio"
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"net/url"
12+
"slices"
13+
"strings"
14+
15+
"github.com/quay/zlog"
16+
17+
"github.com/quay/claircore"
18+
"github.com/quay/claircore/enricher"
19+
"github.com/quay/claircore/libvuln/driver"
20+
"github.com/quay/claircore/pkg/tmp"
21+
)
22+
23+
var (
24+
_ driver.Enricher = (*Enricher)(nil)
25+
_ driver.EnrichmentUpdater = (*Enricher)(nil)
26+
27+
defaultFeed *url.URL
28+
)
29+
30+
const (
31+
// Type is the type of data returned from the Enricher's Enrich method.
32+
Type = `message/vnd.clair.map.vulnerability; enricher=clair.kev schema=none`
33+
// DefaultFeed is the default place to look for the CISA Known Exploited Vulnerabilities feed.
34+
//
35+
//doc:url updater
36+
DefaultFeed = `https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json`
37+
38+
// This appears above and must be the same.
39+
name = `clair.kev`
40+
)
41+
42+
func init() {
43+
var err error
44+
defaultFeed, err = url.Parse(DefaultFeed)
45+
if err != nil {
46+
panic(err)
47+
}
48+
}
49+
50+
// NewFactory creates a Factory for the CISA KEV enricher.
51+
func NewFactory() driver.UpdaterSetFactory {
52+
set := driver.NewUpdaterSet()
53+
_ = set.Add(&Enricher{})
54+
return driver.StaticSet(set)
55+
}
56+
57+
// Enricher provides exploit data as enrichments to a VulnerabilityReport.
58+
//
59+
// Configure must be called before any other methods.
60+
type Enricher struct {
61+
driver.NoopUpdater
62+
c *http.Client
63+
feed *url.URL
64+
}
65+
66+
// Config is the configuration for Enricher.
67+
type Config struct {
68+
Feed *string `json:"feed_root" yaml:"feed"`
69+
}
70+
71+
// Configure implements driver.Configurable.
72+
func (e *Enricher) Configure(_ context.Context, f driver.ConfigUnmarshaler, c *http.Client) error {
73+
var cfg Config
74+
e.c = c
75+
if err := f(&cfg); err != nil {
76+
return err
77+
}
78+
e.feed = defaultFeed
79+
if cfg.Feed != nil {
80+
u, err := url.Parse(*cfg.Feed)
81+
if err != nil {
82+
return err
83+
}
84+
e.feed = u
85+
}
86+
return nil
87+
}
88+
89+
// Name implements driver.Enricher and driver.EnrichmentUpdater.
90+
func (*Enricher) Name() string { return name }
91+
92+
// FetchEnrichment implements driver.EnrichmentUpdater.
93+
func (e *Enricher) FetchEnrichment(ctx context.Context, hint driver.Fingerprint) (io.ReadCloser, driver.Fingerprint, error) {
94+
ctx = zlog.ContextWithValues(ctx, "component", "enricher/kev/Enricher.FetchEnrichment")
95+
96+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.feed.String(), nil)
97+
if err != nil {
98+
return nil, hint, fmt.Errorf("kev: unable to create request: %w", err)
99+
}
100+
if hint != "" {
101+
// Note: Though the default URL returns an etag, the server does not seem to respond
102+
// to the If-None-Match header. It seems like it does respond to If-Modified-Since, though,
103+
// so the timestamp is used as the hint.
104+
req.Header.Set("If-Modified-Since", string(hint))
105+
}
106+
res, err := e.c.Do(req)
107+
if err != nil {
108+
return nil, hint, fmt.Errorf("kev: unable to do request: %w", err)
109+
}
110+
defer res.Body.Close()
111+
112+
switch res.StatusCode {
113+
case http.StatusOK:
114+
if t := string(hint); t == "" || t != res.Header.Get("Last-Modified") {
115+
break
116+
}
117+
fallthrough
118+
case http.StatusNotModified:
119+
zlog.Info(ctx).Msg("database unchanged since last fetch")
120+
return nil, hint, driver.Unchanged
121+
default:
122+
return nil, hint, fmt.Errorf("http response error: %s %d", res.Status, res.StatusCode)
123+
}
124+
zlog.Debug(ctx).Msg("successfully requested database")
125+
126+
out, err := tmp.NewFile("", "kev.")
127+
if err != nil {
128+
return nil, hint, fmt.Errorf("kev: unable to create temp file: %w", err)
129+
}
130+
var success bool
131+
defer func() {
132+
if !success {
133+
if err := out.Close(); err != nil {
134+
zlog.Warn(ctx).Err(err).Msg("unable to close spool")
135+
}
136+
}
137+
}()
138+
139+
// The file size at the time of writing was around 1.1MB, so it seems like a good idea to buffer.
140+
buf := bufio.NewReader(res.Body)
141+
_, err = io.Copy(out, buf)
142+
if err != nil {
143+
return nil, hint, fmt.Errorf("failed to read enrichment: %w", err)
144+
}
145+
146+
if _, err := out.Seek(0, io.SeekStart); err != nil {
147+
return nil, hint, fmt.Errorf("unable to reset spool: %w", err)
148+
}
149+
150+
success = true
151+
hint = driver.Fingerprint(res.Header.Get("Last-Modified"))
152+
zlog.Debug(ctx).
153+
Str("hint", string(hint)).
154+
Msg("using new hint")
155+
156+
return out, hint, nil
157+
}
158+
159+
// ParseEnrichment implements driver.EnrichmentUpdater.
160+
func (e *Enricher) ParseEnrichment(ctx context.Context, rc io.ReadCloser) ([]driver.EnrichmentRecord, error) {
161+
ctx = zlog.ContextWithValues(ctx, "component", "enricher/kev/Enricher.ParseEnrichment")
162+
163+
var root Root
164+
buf := bufio.NewReader(rc)
165+
if err := json.NewDecoder(buf).Decode(&root); err != nil {
166+
return nil, fmt.Errorf("failed to parse enrichment: %w", err)
167+
}
168+
169+
// The self-declared count is probably pretty accurate.
170+
// As of writing this, the count is 1360, so it's rather small.
171+
recs := make([]driver.EnrichmentRecord, 0, root.Count)
172+
for _, vuln := range root.Vulnerabilities {
173+
entry := Entry{
174+
CVE: vuln.CVEID,
175+
VulnerabilityName: vuln.VulnerabilityName,
176+
CatalogVersion: root.CatalogVersion,
177+
DateAdded: vuln.DateAdded,
178+
ShortDescription: vuln.ShortDescription,
179+
RequiredAction: vuln.RequiredAction,
180+
DueDate: vuln.DueDate,
181+
KnownRansomwareCampaignUse: vuln.KnownRansomwareCampaignUse,
182+
}
183+
enrichment, err := json.Marshal(&entry)
184+
if err != nil {
185+
return nil, fmt.Errorf("failed to encode enrichment: %w", err)
186+
}
187+
188+
recs = append(recs, driver.EnrichmentRecord{
189+
Tags: []string{vuln.CVEID},
190+
Enrichment: enrichment,
191+
})
192+
}
193+
194+
return recs, nil
195+
}
196+
197+
// Enrich implements driver.Enricher.
198+
func (e *Enricher) Enrich(ctx context.Context, g driver.EnrichmentGetter, r *claircore.VulnerabilityReport) (string, []json.RawMessage, error) {
199+
ctx = zlog.ContextWithValues(ctx, "component", "enricher/kev/Enricher.Enrich")
200+
201+
m := make(map[string][]json.RawMessage)
202+
erCache := make(map[string][]driver.EnrichmentRecord)
203+
204+
for id, v := range r.Vulnerabilities {
205+
t := make(map[string]struct{})
206+
ctx := zlog.ContextWithValues(ctx, "vuln", v.Name)
207+
208+
for _, elem := range []string{
209+
v.Description,
210+
v.Name,
211+
v.Links,
212+
} {
213+
// Check if the element is non-empty before running the regex
214+
if elem == "" {
215+
zlog.Debug(ctx).Str("element", elem).Msg("skipping empty element")
216+
continue
217+
}
218+
219+
matches := enricher.CVERegexp.FindAllString(elem, -1)
220+
if len(matches) == 0 {
221+
zlog.Debug(ctx).Str("element", elem).Msg("no CVEs found in element")
222+
continue
223+
}
224+
for _, m := range matches {
225+
t[m] = struct{}{}
226+
}
227+
}
228+
229+
// Skip if no CVEs were found
230+
if len(t) == 0 {
231+
zlog.Debug(ctx).Msg("no CVEs found in vulnerability metadata")
232+
continue
233+
}
234+
235+
ts := make([]string, 0, len(t))
236+
for m := range t {
237+
ts = append(ts, m)
238+
}
239+
slices.Sort(ts)
240+
241+
cveKey := strings.Join(ts, "_")
242+
243+
rec, ok := erCache[cveKey]
244+
if !ok {
245+
var err error
246+
rec, err = g.GetEnrichment(ctx, ts)
247+
if err != nil {
248+
return "", nil, err
249+
}
250+
erCache[cveKey] = rec
251+
}
252+
253+
zlog.Debug(ctx).Int("count", len(rec)).Msg("found records")
254+
255+
// Skip if no enrichment records are found
256+
if len(rec) == 0 {
257+
zlog.Debug(ctx).Strs("cve", ts).Msg("no enrichment records found for CVEs")
258+
continue
259+
}
260+
261+
for _, r := range rec {
262+
m[id] = append(m[id], r.Enrichment)
263+
}
264+
}
265+
266+
if len(m) == 0 {
267+
return Type, nil, nil
268+
}
269+
270+
b, err := json.Marshal(m)
271+
if err != nil {
272+
return Type, nil, err
273+
}
274+
return Type, []json.RawMessage{b}, nil
275+
}

0 commit comments

Comments
 (0)