Post

Don't Want to Miss a Ball with OpenCVE

A new Slack channel was created — the “Security” channel — meant to cover all sorts of security topics: CVEs, hacker news, disclosures with a high CVSS score, and so on. Team members started feeding the channel with whatever they’d learned, whether from personal subscriptions, RSS feeds, or just stumbling across it in the news; customer feedback played a role too. Over time, I couldn’t help but wonder whether critical security issues were slipping through — issues our incident-reporting channel had actually missed. The odds are high, since human input is subject to all sorts of factors. What I needed was an automated, systematic way to make sure we don’t drop the ball. That’s not to say channel members are getting kicked out — customer feedback still matters for prioritization, and the banter keeps the team’s vibe alive.

OpenCVE stood out as a good candidate, both for its popularity and its open-source nature. Not only does it offer a hosted service, but — more importantly — it gives me the freedom to build a self-hosted instance right into our own CI pipelines. The goal is to automate setting up an OpenCVE service from the ground up, so the whole thing can be rebuilt or ported elsewhere with ease. Once it’s up and running, the Grype-scanned vulnerability report from every build will automatically filter out the critical findings and subscribe to them on OpenCVE, which will then notify the security channel whenever there’s news about those subscribed packages.

Let’s get started with my favorite build tool: Makefile.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# ── OpenCVE Environment Config ────────────────────────────────────────────────
OPENCVE_REPO         := https://github.com/opencve/opencve.git
OPENCVE_DIR          := /tmp/opencve
OPENCVE_HTTP_PORT    := 8090
OPENCVE_AIRFLOW_PORT := 8091

# Base URL Airflow's Slack/email notifier builds CVE links from
# (scheduler/airflow.cfg [opencve] web_base_url). Upstream ships this as the
# literal placeholder "<your_listening_ip or fqdn>" and never fills it in,
# which breaks Slack's mrkdwn link parsing (the raw "<...|...>" shows up
# unrendered in the channel instead of a link). Override on the command line
# if this host's IP isn't the one people should click through to.
OPENCVE_WEB_BASE_URL ?= http://$(shell hostname -I | awk '{print $$1}'):$(OPENCVE_HTTP_PORT)

OPENCVE_LATEST_VERSION := $(shell git ls-remote --tags $(OPENCVE_REPO) | awk -F'/' '{print $$3}' | grep -E '^v?[0-9]' | sort -V | tail -1)
OPENCVE_INSTALLED_SRC := $(firstword $(wildcard $(OPENCVE_DIR)/opencve-*))
ifneq ($(OPENCVE_INSTALLED_SRC),)
OPENCVE_VERSION := v$(patsubst opencve-%,%,$(notdir $(OPENCVE_INSTALLED_SRC)))
else
OPENCVE_VERSION := $(OPENCVE_LATEST_VERSION)
endif
OPENCVE_SRC     := $(OPENCVE_DIR)/opencve-$(patsubst v%,%,$(OPENCVE_VERSION))
OPENCVE_RUNNING := $(shell docker inspect -f '' opencve-webserver-1 2>/dev/null)

OPENCVE_ADMIN_USER  := leechienpang
OPENCVE_ADMIN_EMAIL := lee.chienpang@gmail.com
OPENCVE_ADMIN_PASS  ?= 2026-08-24

OPENCVE_KB_DIR              := /home/airflow/repositories
OPENCVE_KB_IMPORTED_MARKER  := $(OPENCVE_SRC)/docker/.opencve_kb_imported
OPENCVE_SECRET_KEY_MARKER   := $(OPENCVE_SRC)/docker/.opencve_secret_key_set

OPENCVE_MK_DIR             := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))/
OPENCVE_ORG_NAME           := MYORG
OPENCVE_PROJECT_NAME       := MYPROJ
OPENCVE_SUBSCRIBED_VENDORS := mariadb

.PHONY: help
help::
	@echo "opencve                       Fetch latest OpenCVE release, prepare and start it (fully populated) on port $(OPENCVE_HTTP_PORT)"
	@echo "opencve-version               Print the latest OpenCVE release tag"
	@echo "opencve-airflow-connections   (Re)set the Airflow<->Postgres/Redis connections (idempotent)"
	@echo "opencve-clone-kb              (Re)clone the OpenCVE KB source repos (resumable, skips ones already present)"
	@echo "opencve-import-kb             Bulk-import CVEs/vendors/products into the DB (first run only, ~10-30 min)"
	@echo "opencve-unpause-dag           Unpause the Airflow opencve DAG (hourly incremental sync)"
	@echo "opencve-subscribe-vendors     Ensure org '$(OPENCVE_ORG_NAME)' / project '$(OPENCVE_PROJECT_NAME)' subscribe to: $(OPENCVE_SUBSCRIBED_VENDORS)"
	@echo "opencve-unsubscribe-all       Clear all vendor/product subscriptions for the project (reset to empty)"
	@echo "grype-crit                    List Critical findings in a vuln.json, caches them for opencve-subscribe (VULN_JSON=...)"
	@echo "grype-high                    List High findings in a vuln.json, caches them for opencve-subscribe (VULN_JSON=...)"
	@echo "opencve-subscribe             Subscribe vendors/products from the last grype-crit and/or grype-high run(s)"
	@echo "opencve-superuser             (Re)create the OpenCVE admin user $(OPENCVE_ADMIN_USER)"
	@echo "opencve-rm                    Stop OpenCVE containers (volumes preserved)"
	@echo "opencve-clean                 Stop containers, remove volumes and downloaded source"

.PHONY: opencve-version
opencve-version:
	@echo "==> Latest OpenCVE release: $(OPENCVE_LATEST_VERSION)"
	@[ -z "$(OPENCVE_INSTALLED_SRC)" ] || echo "==> Currently installed: $(OPENCVE_VERSION) ($(OPENCVE_SRC))"

# ── Fetch, Prepare & Start ─────────────────────────────────────────────────────
.PHONY: opencve
opencve: opencve-version
	@mkdir -p $(OPENCVE_DIR)
	@test -d $(OPENCVE_SRC)/.git || (rm -rf $(OPENCVE_SRC) && git clone --depth 1 --branch $(OPENCVE_VERSION) $(OPENCVE_REPO) $(OPENCVE_SRC))
	$(Q)cd $(OPENCVE_SRC)/docker && ./install.sh -r $(OPENCVE_VERSION) prepare
	$(Q)cd $(OPENCVE_SRC)/docker && sed -i.bak -e "s/^OPENCVE_PORT=.*/OPENCVE_PORT=$(OPENCVE_HTTP_PORT)/" -e "s/^AIRFLOW_WEBSERVER_PORT=.*/AIRFLOW_WEBSERVER_PORT=$(OPENCVE_AIRFLOW_PORT)/" .env
	$(Q)rm -f $(OPENCVE_SRC)/docker/.env.bak
	$(Q)cd $(OPENCVE_SRC)/docker && sed -i.bak -e 's/Host $$host;/Host $$http_host;/' conf/opencve.conf.template
	$(Q)rm -f $(OPENCVE_SRC)/docker/conf/opencve.conf.template.bak
	$(Q)sed -i.bak -e "s|^web_base_url = .*|web_base_url = $(OPENCVE_WEB_BASE_URL)|" $(OPENCVE_SRC)/scheduler/airflow.cfg
	$(Q)rm -f $(OPENCVE_SRC)/scheduler/airflow.cfg.bak
	$(Q)cd $(OPENCVE_SRC)/docker && ./install.sh docker-up
	$(Q)$(MAKE) opencve-airflow-connections
	@test -f $(OPENCVE_SECRET_KEY_MARKER) || ( \
		cd $(OPENCVE_SRC)/docker && \
		./install.sh init-secret-key && \
		docker compose restart webserver && \
		touch $(OPENCVE_SECRET_KEY_MARKER) \
	)
	$(Q)$(MAKE) opencve-clone-kb
	$(Q)$(MAKE) opencve-import-kb
	@[ "$(OPENCVE_RUNNING)" = "true" ] || (cd $(OPENCVE_SRC)/docker && docker compose exec -T -e DJANGO_SUPERUSER_USERNAME=$(OPENCVE_ADMIN_USER) -e DJANGO_SUPERUSER_EMAIL=$(OPENCVE_ADMIN_EMAIL) -e DJANGO_SUPERUSER_PASSWORD=$(OPENCVE_ADMIN_PASS) webserver python manage.py createsuperuser --noinput || true)
	@[ "$(OPENCVE_RUNNING)" = "true" ] || (cd $(OPENCVE_SRC)/docker && docker compose exec -T postgres psql -U opencve -d opencve -c "INSERT INTO account_emailaddress(email, verified, \"primary\", user_id) SELECT email, 1::bool, 1::bool, id FROM opencve_users WHERE username='$(OPENCVE_ADMIN_USER)' ON CONFLICT (user_id, email) DO NOTHING;")
	$(Q)$(MAKE) opencve-unpause-dag
	$(Q)$(MAKE) opencve-subscribe-vendors
	@[ "$(OPENCVE_RUNNING)" = "true" ] && echo "==> OpenCVE stack already running (skipped re-init)" || true
	@echo "==> OpenCVE web UI: http://localhost:$(OPENCVE_HTTP_PORT)"
	@echo "==> Airflow UI:     http://localhost:$(OPENCVE_AIRFLOW_PORT) (airflow/airflow)"
	@echo "==> Admin login: $(OPENCVE_ADMIN_USER) / $(OPENCVE_ADMIN_PASS)"
	@echo "==> Org '$(OPENCVE_ORG_NAME)' / project '$(OPENCVE_PROJECT_NAME)' subscribed to: $(OPENCVE_SUBSCRIBED_VENDORS)"
	@echo "==> Notification links (Slack/email) point to: $(OPENCVE_WEB_BASE_URL)"

# Upstream install.sh's set-airflow-connections calls "airflow connections add"
# unconditionally, which fails if the connection already exists (e.g. after
# "opencve-rm" + "make opencve" — volumes persist, so the Airflow metadata DB
# still has it). Delete-then-add makes this idempotent across stop/start cycles.
.PHONY: opencve-airflow-connections
opencve-airflow-connections:
	@echo "==> Ensuring Airflow connections (idempotent)"
	$(Q)cd $(OPENCVE_SRC)/docker && \
		export $$(grep -v '^#' .env | grep -E '^POSTGRES' | tr '\n' ' ') && \
		(docker compose exec -T airflow-scheduler airflow connections delete opencve_postgres >/dev/null 2>&1 || true) && \
		docker compose exec -T airflow-scheduler airflow connections add opencve_postgres --conn-uri "postgres://$$POSTGRES_USER:$$POSTGRES_PASSWORD@postgres:5432/opencve" && \
		(docker compose exec -T airflow-scheduler airflow connections delete opencve_redis >/dev/null 2>&1 || true) && \
		docker compose exec -T airflow-scheduler airflow connections add opencve_redis --conn-uri "redis://redis:6379" --conn-extra '{"db": 3}'

# ── KB Repositories: clone, bulk import, enable incremental sync ────────────────
# Mirrors upstream install.sh's clone-repositories / import-opencve-kb / start-opencve-dag,
# but each step is idempotent/resumable so re-running "make opencve" always converges
# to a fully populated, working service regardless of prior partial state.
.PHONY: opencve-clone-kb
opencve-clone-kb:
	@echo "==> Cloning OpenCVE KB repositories (skips ones already present; cvelistV5 is large)"
	$(Q)cd $(OPENCVE_SRC)/docker && docker compose exec -T airflow-scheduler sh -c 'test -d $(OPENCVE_KB_DIR)/opencve-kb/.git || git clone https://github.com/opencve/opencve-kb.git $(OPENCVE_KB_DIR)/opencve-kb'
	$(Q)cd $(OPENCVE_SRC)/docker && docker compose exec -T airflow-scheduler sh -c 'test -d $(OPENCVE_KB_DIR)/opencve-nvd/.git || git clone https://github.com/opencve/opencve-nvd.git $(OPENCVE_KB_DIR)/opencve-nvd'
	$(Q)cd $(OPENCVE_SRC)/docker && docker compose exec -T airflow-scheduler sh -c 'test -d $(OPENCVE_KB_DIR)/opencve-redhat/.git || git clone https://github.com/opencve/opencve-redhat.git $(OPENCVE_KB_DIR)/opencve-redhat'
	$(Q)cd $(OPENCVE_SRC)/docker && docker compose exec -T airflow-scheduler sh -c 'test -d $(OPENCVE_KB_DIR)/cvelistV5/.git || git clone https://github.com/CVEProject/cvelistV5.git $(OPENCVE_KB_DIR)/cvelistV5'
	$(Q)cd $(OPENCVE_SRC)/docker && docker compose exec -T airflow-scheduler sh -c 'test -d $(OPENCVE_KB_DIR)/vulnrichment/.git || git clone https://github.com/cisagov/vulnrichment.git $(OPENCVE_KB_DIR)/vulnrichment'
	@echo "==> KB repositories ready"

.PHONY: opencve-import-kb
opencve-import-kb:
	@test -f $(OPENCVE_KB_IMPORTED_MARKER) && echo "==> CVE data already imported (skipped; rm $(OPENCVE_KB_IMPORTED_MARKER) to force a re-import)" || ( \
		echo "==> Importing CVEs/vendors/products into the database (first run only, ~10-30 min)..." && \
		cd $(OPENCVE_SRC)/docker && docker compose exec -T webserver python manage.py import_cves && \
		touch $(OPENCVE_KB_IMPORTED_MARKER) && \
		echo "==> CVE import complete" \
	)

.PHONY: opencve-unpause-dag
opencve-unpause-dag:
	$(Q)cd $(OPENCVE_SRC)/docker && docker compose exec -T airflow-scheduler airflow dags unpause opencve
	@echo "==> OpenCVE Airflow DAG unpaused (hourly incremental sync)"

# Ensures the org/project exist and subscribe to OPENCVE_SUBSCRIBED_VENDORS.
# Idempotent (get_or_create + set union), safe to run on every "make opencve".
# The vendors REST API is read-only (no create/update), so this goes through
# manage.py shell/the ORM instead, same as install.sh's own one-off admin steps.
.PHONY: opencve-subscribe-vendors
opencve-subscribe-vendors:
	$(Q)cd $(OPENCVE_SRC)/docker && docker compose exec -T \
		-e OPENCVE_SUBSCRIBE_USER=$(OPENCVE_ADMIN_USER) \
		-e OPENCVE_SUBSCRIBE_ORG=$(OPENCVE_ORG_NAME) \
		-e OPENCVE_SUBSCRIBE_PROJECT=$(OPENCVE_PROJECT_NAME) \
		-e OPENCVE_SUBSCRIBE_VENDORS="$(OPENCVE_SUBSCRIBED_VENDORS)" \
		webserver python manage.py shell < $(OPENCVE_MK_DIR)opencve_subscribe.py

# Resets the project's subscriptions back to empty (vendors and products).
# Useful to clear out over-broad subscriptions before re-running a corrected
# subscribe target, or just to start clean. Not run automatically by anything.
.PHONY: opencve-unsubscribe-all
opencve-unsubscribe-all:
	$(Q)cd $(OPENCVE_SRC)/docker && docker compose exec -T \
		-e OPENCVE_SUBSCRIBE_ORG=$(OPENCVE_ORG_NAME) \
		-e OPENCVE_SUBSCRIBE_PROJECT=$(OPENCVE_PROJECT_NAME) \
		webserver python manage.py shell < $(OPENCVE_MK_DIR)opencve_unsubscribe_all.py

# Reads a Syft/Grype vuln.json report and
# lists every finding of one severity level (CVE/GHSA/GO id, fix state,
# affected package(s)). As a side effect, caches the CVE -> package-names
# mapping to a temp file so opencve-subscribe can reuse it without
# re-parsing the whole (often 100+MB) vuln.json again.
# The listing script itself lives in ../grype (shared with the standalone
# "make -C triangle/grype" targets); this just wires it to the
# opencve-subscribe cache files.
# Usage: make grype-crit VULN_JSON=/path/to/vuln.json
#        make grype-high VULN_JSON=/path/to/vuln.json
VULN_JSON           ?=
# Tagged with the invoking user's uid -- see the same fix in ../grype/grype.mk
# for why (a root-owned cache from manual testing otherwise blocks a Jenkins
# job, which runs as a non-root user, from overwriting the same /tmp path).
OPENCVE_TMP_TAG             := $(shell id -u)
OPENCVE_GRYPE_CRIT_FINDINGS := /tmp/opencve_grype_findings_critical_$(OPENCVE_TMP_TAG).json
OPENCVE_GRYPE_HIGH_FINDINGS := /tmp/opencve_grype_findings_high_$(OPENCVE_TMP_TAG).json
OPENCVE_GRYPE_LIST_PY       := $(OPENCVE_MK_DIR)../grype/grype_list.py

.PHONY: grype-crit
grype-crit:
	@test -n "$(VULN_JSON)" || (echo "==> ERROR: pass VULN_JSON=/path/to/*_vuln.json"; exit 1)
	@test -f "$(VULN_JSON)" || (echo "==> ERROR: file not found: $(VULN_JSON)"; exit 1)
	$(Q)python3 $(OPENCVE_GRYPE_LIST_PY) $(VULN_JSON) Critical $(OPENCVE_GRYPE_CRIT_FINDINGS)

.PHONY: grype-high
grype-high:
	@test -n "$(VULN_JSON)" || (echo "==> ERROR: pass VULN_JSON=/path/to/*_vuln.json"; exit 1)
	@test -f "$(VULN_JSON)" || (echo "==> ERROR: file not found: $(VULN_JSON)"; exit 1)
	$(Q)python3 $(OPENCVE_GRYPE_LIST_PY) $(VULN_JSON) High $(OPENCVE_GRYPE_HIGH_FINDINGS)

# Subscribes vendor/product entries for whatever grype-crit/grype-high cached
# (either or both — run one or both right before this) to the OpenCVE
# project, but only ones matching the actual scanned package name
# (best-effort token match), not a CVE's full vendors list (which NVD/MITRE
# often tags with many unrelated downstream vendors sharing the same
# underlying vulnerable component). CVE IDs not yet present in OpenCVE's
# own DB are reported, not silently dropped.
# Usage: make grype-crit opencve-subscribe VULN_JSON=/path/to/vuln.json          (Critical only)
#        make grype-crit grype-high opencve-subscribe VULN_JSON=/path/to/vuln.json (Critical + High)
.PHONY: opencve-subscribe
opencve-subscribe:
	@test -f "$(OPENCVE_GRYPE_CRIT_FINDINGS)" -o -f "$(OPENCVE_GRYPE_HIGH_FINDINGS)" || (echo "==> ERROR: run 'make grype-crit' and/or 'make grype-high' first (VULN_JSON=...)"; exit 1)
	$(Q)cd $(OPENCVE_SRC)/docker && docker compose exec -T \
		-e OPENCVE_SUBSCRIBE_ORG=$(OPENCVE_ORG_NAME) \
		-e OPENCVE_SUBSCRIBE_PROJECT=$(OPENCVE_PROJECT_NAME) \
		-e OPENCVE_GRYPE_CRIT_FINDINGS="$$([ -f $(OPENCVE_GRYPE_CRIT_FINDINGS) ] && cat $(OPENCVE_GRYPE_CRIT_FINDINGS) || echo '{}')" \
		-e OPENCVE_GRYPE_HIGH_FINDINGS="$$([ -f $(OPENCVE_GRYPE_HIGH_FINDINGS) ] && cat $(OPENCVE_GRYPE_HIGH_FINDINGS) || echo '{}')" \
		webserver python manage.py shell < $(OPENCVE_MK_DIR)opencve_grype_subscribe.py

.PHONY: opencve-superuser
opencve-superuser:
	$(Q)cd $(OPENCVE_SRC)/docker && docker compose exec -T -e DJANGO_SUPERUSER_USERNAME=$(OPENCVE_ADMIN_USER) -e DJANGO_SUPERUSER_EMAIL=$(OPENCVE_ADMIN_EMAIL) -e DJANGO_SUPERUSER_PASSWORD=$(OPENCVE_ADMIN_PASS) webserver python manage.py createsuperuser --noinput || true
	$(Q)cd $(OPENCVE_SRC)/docker && docker compose exec -T postgres psql -U opencve -d opencve -c "INSERT INTO account_emailaddress(email, verified, \"primary\", user_id) SELECT email, 1::bool, 1::bool, id FROM opencve_users WHERE username='$(OPENCVE_ADMIN_USER)' ON CONFLICT (user_id, email) DO NOTHING;"
	@echo "==> Admin user ready: $(OPENCVE_ADMIN_USER) / $(OPENCVE_ADMIN_PASS)"

# ── Stop & Remove ──────────────────────────────────────────────────────────────
.PHONY: opencve-rm
opencve-rm:
	$(Q)test -d $(OPENCVE_SRC)/docker && (cd $(OPENCVE_SRC)/docker && docker compose down) || echo "==> No OpenCVE source found under $(OPENCVE_SRC)"
	@echo "==> OpenCVE stopped (volumes preserved)"

.PHONY: opencve-clean
opencve-clean:
	$(Q)test -d $(OPENCVE_SRC)/docker && (cd $(OPENCVE_SRC)/docker && docker compose down -v) || echo "==> No OpenCVE source found under $(OPENCVE_SRC)"
	@rm -rf $(OPENCVE_DIR)
	@echo "==> OpenCVE removed (volumes deleted, source tree removed)"

The Makefile leans on the REST API for reads but drops into manage.py shell for anything that mutates org/project subscriptions, since the vendors API is read-only. Here are the three scripts it pipes in:

opencve_subscribe.py — the one opencve-subscribe-vendors uses to get an org/project onto the vendor list from the Makefile config:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import os

from django.utils import timezone
from organizations.models import Membership, Organization
from projects.models import Project
from users.models import User

user = User.objects.get(username=os.environ["OPENCVE_SUBSCRIBE_USER"])

org, _ = Organization.objects.get_or_create(name=os.environ["OPENCVE_SUBSCRIBE_ORG"])
Membership.objects.get_or_create(
    user=user,
    organization=org,
    defaults={"role": Membership.OWNER, "date_joined": timezone.now()},
)

project, _ = Project.objects.get_or_create(
    name=os.environ["OPENCVE_SUBSCRIBE_PROJECT"], organization=org
)
vendors = os.environ.get("OPENCVE_SUBSCRIBE_VENDORS", "").split()
project.subscriptions["vendors"] = sorted(set(project.subscriptions["vendors"]) | set(vendors))
project.save()

print(f"==> Project '{project.name}' (org '{org.name}') subscriptions: {project.subscriptions}")

opencve_unsubscribe_all.py — what opencve-unsubscribe-all runs to wipe a project’s subscriptions back to empty:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import os

from organizations.models import Organization
from projects.models import Project

org = Organization.objects.get(name=os.environ["OPENCVE_SUBSCRIBE_ORG"])
project = Project.objects.get(
    name=os.environ["OPENCVE_SUBSCRIBE_PROJECT"], organization=org
)

before_vendors = len(project.subscriptions["vendors"])
before_products = len(project.subscriptions["products"])

project.subscriptions = {"vendors": [], "products": []}
project.save()

print(
    f"==> Cleared subscriptions for project '{project.name}': "
    f"removed {before_vendors} vendor(s), {before_products} product(s)"
)

opencve_grype_subscribe.py — the more interesting one, wired to opencve-subscribe. It takes whatever grype-crit/grype-high cached (a CVE ID → scanned-package-name mapping) and only subscribes to the vendor/product entries that actually match a package name found in the scan, instead of every vendor NVD/MITRE happens to tag on the CVE (which is usually a much longer, mostly-irrelevant list):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import json
import os
import re

from cves.models import Cve
from organizations.models import Organization
from projects.models import Project

# {cve_id: [package_name, ...]} for the packages actually found in the scan.
# Merges whatever grype-crit/grype-high cached — either or both may be
# present depending on which was run before this.
crit_findings = json.loads(os.environ.get("OPENCVE_GRYPE_CRIT_FINDINGS", "{}") or "{}")
high_findings = json.loads(os.environ.get("OPENCVE_GRYPE_HIGH_FINDINGS", "{}") or "{}")
findings = {**crit_findings, **high_findings}
cve_ids = sorted(findings)

org = Organization.objects.get(name=os.environ["OPENCVE_SUBSCRIBE_ORG"])
project = Project.objects.get(
    name=os.environ["OPENCVE_SUBSCRIBE_PROJECT"], organization=org
)

found = list(Cve.objects.filter(cve_id__in=cve_ids).values_list("cve_id", "vendors"))
found_ids = {cve_id for cve_id, _ in found}
missing_ids = sorted(set(cve_ids) - found_ids)


def match_tokens(pkg_names):
    # Package manager names (rpm, npm, go modules, ...) don't map 1:1 onto
    # NVD/CPE vendor:product slugs, so this is a best-effort substring match:
    # split each scanned package name into alnum tokens (>=3 chars, to skip
    # noise like version-ish "v5"), then keep a CVE's vendor/product entry
    # only if one of those tokens shows up in it.
    tokens = set()
    for name in pkg_names:
        tokens |= {t for t in re.split(r"[^a-z0-9]+", name.lower()) if len(t) >= 3}
    return tokens


new_vendors = set()
new_products = set()
skipped = set()
for cve_id, vendors in found:
    tokens = match_tokens(findings.get(cve_id, []))
    for entry in vendors:
        if any(tok in entry.lower() for tok in tokens):
            (new_products if "$PRODUCT$" in entry else new_vendors).add(entry)
        else:
            skipped.add(entry)

before_vendors = set(project.subscriptions["vendors"])
before_products = set(project.subscriptions["products"])

project.subscriptions["vendors"] = sorted(before_vendors | new_vendors)
project.subscriptions["products"] = sorted(before_products | new_products)
project.save()

print(
    f"==> CVE IDs given: {len(cve_ids)} | matched in OpenCVE: {len(found_ids)} | not in OpenCVE DB: {len(missing_ids)}"
)
if missing_ids:
    print(f"==> Not found (skipped): {' '.join(missing_ids)}")
print(
    f"==> Unrelated vendor/product entries skipped (didn't match a scanned package name): {len(skipped)}"
)
print(f"==> New vendor subscriptions: {sorted(new_vendors - before_vendors) or 'none'}")
print(f"==> New product subscriptions: {sorted(new_products - before_products) or 'none'}")
print(
    f"==> Project '{project.name}' now subscribes to "
    f"{len(project.subscriptions['vendors'])} vendor(s), "
    f"{len(project.subscriptions['products'])} product(s)"
)

This Makefile covers it all — here’s a quick walkthrough of the flow.

1
2
$ make opencve-version
==> Latest OpenCVE release: v3.1.0

The opencve-version target is a prerequisite of opencve, so it always picks up the latest release.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
[root@opencve-host opencve]# docker ps | grep opencve
87e5c98ce366   opencve-airflow-worker        "/usr/bin/dumb-init …"   2 weeks ago    Up 2 weeks (healthy)   8080/tcp                                                                                                                          opencve-airflow-worker-1
a4f1aa977df5   opencve-airflow-webserver     "/usr/bin/dumb-init …"   2 weeks ago    Up 2 weeks (healthy)   0.0.0.0:8091->8080/tcp, :::8091->8080/tcp                                                                                         opencve-airflow-webserver-1
2357abbd4342   opencve-airflow-scheduler     "/usr/bin/dumb-init …"   2 weeks ago    Up 2 weeks (healthy)   8080/tcp                                                                                                                          opencve-airflow-scheduler-1
bff2c5df6a8b   nginx:trixie                  "/docker-entrypoint.…"   2 weeks ago    Up 2 weeks             0.0.0.0:8090->80/tcp, :::8090->80/tcp                                                                                             opencve-nginx-1
c33d768d95ef   opencve-webserver             "gunicorn opencve.ws…"   2 weeks ago    Up 2 weeks             8000/tcp                                                                                                                          opencve-webserver-1
998c219d5bf6   postgres:15                   "docker-entrypoint.s…"   2 weeks ago    Up 2 weeks (healthy)   5432/tcp                                                                                                                          opencve-postgres-1
28b91739868f   redis/redis-stack:latest      "/entrypoint.sh"         2 weeks ago    Up 2 weeks (healthy)   6379/tcp, 8001/tcp                                                                                                                opencve-redis-1
[root@opencve-host opencve]# make opencve-version
==> Latest OpenCVE release: v3.1.0
==> Currently installed: v3.0.0 (/opt/opencve/opencve-3.0.0)
[root@opencve-host opencve]# make opencve-rm
test -d /opt/opencve/opencve-3.0.0/docker && (cd /opt/opencve/opencve-3.0.0/docker && docker compose down) || echo "==> No OpenCVE source found under /opt/opencve/opencve-3.0.0"
[+] Running 0/0
 ⠋ Container opencve-airflow-webserver-1  Stopping                                                                                                     0.1s 
 ⠋ Container opencve-airflow-scheduler-1  Stopping                                                                                                     0.1s 
 ⠋ Container opencve-webserver-1          Stopping                                                                                                     0.1s 
 ⠋ Container opencve-nginx-1              Stopping                                                                                                     0.1s 
 ⠋ Container opencve-airflow-worker-1     Stopping                                                                                                     0.1s 
[+] Running 0/5
 ⠙ Container opencve-airflow-webserver-1  Stopping                                                                                                     0.2s 
 ⠙ Container opencve-airflow-scheduler-1  Stopping                                                                                                     0.2s 
 ⠙ Container opencve-webserver-1          Stopping                                                                                                     0.2s 
 ⠙ Container opencve-nginx-1              Stopping                                                                                                     0.2s 
 ⠙ Container opencve-airflow-worker-1     Stopping                                                                                                     0.2s 
[+] Running 0/5
 ⠹ Container opencve-airflow-webserver-1  Stopping                                                                                                     0.3s 
 ⠹ Container opencve-airflow-scheduler-1  Stopping                                                                                                     0.3s 
 ⠹ Container opencve-webserver-1          Stopping                                                                                                     0.3s 
 ⠹ Container opencve-nginx-1              Stopping                                                                                                     0.3s 
 ⠹ Container opencve-airflow-worker-1     Stopping                                                                                                     0.3s 
...
[+] Running 9/9
 ✔ Container opencve-airflow-webserver-1  Removed                                                                                                      2.7s 
 ✔ Container opencve-airflow-scheduler-1  Removed                                                                                                      2.6s 
 ✔ Container opencve-webserver-1          Removed                                                                                                      0.9s 
 ✔ Container opencve-nginx-1              Removed                                                                                                      0.4s 
 ✔ Container opencve-airflow-worker-1     Removed                                                                                                      4.8s 
 ✔ Container opencve-airflow-init-1       Removed                                                                                                      0.2s 
 ✔ Container opencve-postgres-1           Removed                                                                                                      0.4s 
 ✔ Container opencve-redis-1              Removed                                                                                                      0.4s 
 ✔ Network opencve_default                Removed                                                                                                      0.3s 
==> OpenCVE stopped (volumes preserved)
[root@opencve-host opencve]# docker ps | grep opencve
[root@opencve-host opencve]#

make opencve-rm removes the running containers while preserving the downloaded repositories and the knowledge-base database — a big time-saver while testing out different deployments.

1
2
3
4
5
6
7
8
9
[root@opencve-host opencve]# ls -ltd /var/lib/docker/volumes/open*
drwx-----x. 3 root root 4096 Aug 10 12:48 /var/lib/docker/volumes/opencve_staticfiles
drwx-----x. 3 root root 4096 Aug 10 12:48 /var/lib/docker/volumes/opencve_repositories
drwx-----x. 3 root root 4096 Aug 10 12:48 /var/lib/docker/volumes/opencve_postgres-db
[root@opencve-host opencve]# du -hsc /var/lib/docker/volumes/open*
80M     /var/lib/docker/volumes/opencve_postgres-db
9.3G    /var/lib/docker/volumes/opencve_repositories
8.2M    /var/lib/docker/volumes/opencve_staticfiles
9.4G    total

The nuclear option for wiping everything out is opencve-clean, which both kills the services and purges all downloaded data.

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@opencve-host opencve]# make opencve-clean
test -d /opt/opencve/opencve-3.0.0/docker && (cd /opt/opencve/opencve-3.0.0/docker && docker compose down -v) || echo "==> No OpenCVE source found under /opt/opencve/opencve-3.0.0"
[+] Running 0/0
 ⠋ Container opencve-airflow-scheduler-1  Stopping                                                                                                                                                                                                                                                                     0.1s 
 ⠋ Container opencve-webserver-1          Stopping                                                                                                                                                                                                                                                                     0.1s 
 ⠋ Container opencve-nginx-1              Stopping                                                                                                                                                                                                                                                                     0.1s 
 ⠋ Container opencve-airflow-webserver-1  Stopping                                                                                                                                                                                                                                                                     0.1s 
 ⠋ Container opencve-airflow-worker-1     Stopping
 ...

[root@opencve-host opencve]# du -hsc /var/lib/docker/volumes/open*
du: cannot access '/var/lib/docker/volumes/open*': No such file or directory
0       total

Next up is the main course:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
[root@opencve-host opencve]# make opencve
==> Latest OpenCVE release: v3.1.0
==> Currently installed: v3.0.0 (/opt/opencve/opencve-3.0.0)
cd /opt/opencve/opencve-3.0.0/docker && ./install.sh -r v3.0.0 prepare

--------| Find the release to install
> checking out v3.0.0...
Reset branch 'v3.0.0'
> done ✅

--------| Airflow configuration
🟡 ../scheduler/airflow.cfg file is already present.
Use -f option to override with the default file.

--------| Django settings and .env file
🟡 ../web/opencve/conf/settings.py file is already present.
Use -f option to override with the default file.
🟡 ../web/opencve/conf/.env file is already present.
Use -f option to override with the default file.

--------| Docker compose .env file
🟡 ./.env file is already present.
Use -f option to override with the default file.
> updating OpenCVE release to v3.0.0...
> done ✅

--------| Nginx OpenCVE template
🟡 ./conf/opencve.conf.template file is already present.
Use -f option to override with the default file.


🟢 The configuration files are all set, you can update them now if you want before starting the entire docker stack:
 Nginx: ./conf/opencve.conf.template
 Airflow: ../scheduler/airflow.cfg
 Webserver: ../web/opencve/conf/.env & ../web/opencve/conf/settings.py
 Docker compose: ./.env

See the documentation for details: https://docs.opencve.io/deployment/#configuration

 🚀 You can now run: ./install.sh start
cd /opt/opencve/opencve-3.0.0/docker && sed -i.bak -e "s/^OPENCVE_PORT=.*/OPENCVE_PORT=8090/" -e "s/^AIRFLOW_WEBSERVER_PORT=.*/AIRFLOW_WEBSERVER_PORT=8091/" .env
rm -f /opt/opencve/opencve-3.0.0/docker/.env.bak
cd /opt/opencve/opencve-3.0.0/docker && sed -i.bak -e 's/Host $host;/Host $http_host;/' conf/opencve.conf.template
rm -f /opt/opencve/opencve-3.0.0/docker/conf/opencve.conf.template.bak
sed -i.bak -e "s|^web_base_url = .*|web_base_url = http://10.32.200.11:8090|" /opt/opencve/opencve-3.0.0/scheduler/airflow.cfg
rm -f /opt/opencve/opencve-3.0.0/scheduler/airflow.cfg.bak
cd /opt/opencve/opencve-3.0.0/docker && ./install.sh docker-up

--------| Docker compose up
> starting OpenCVE docker stack...
[+] Running 0/0
 ⠋ Network opencve_default  Creating                                                                                                                                                                                                                                                                                   0.1s 
[+] Running 0/1
 ⠙ Network opencve_default  Creating                                                                                                                                                                                                                                                                                   0.2s 
[+] Running 1/1
 ✔ Network opencve_default        Created                                                                                                                                                                                                                                                                              0.2s 
 ⠋ Container opencve-nginx-1      Creating                                                                                                                                                                                                                                                                             0.0s 
 ⠋ Container opencve-postgres-1   Creating                                                                                                                                                                                                                                                                             0.0s 
 ⠋ Container opencve-webserver-1  Creating                                                                                                                                                                                                                                                                             0.0s 
 ⠋ Container opencve-redis-1      Creating
 ...
make opencve-clone-kb
==> Cloning OpenCVE KB repositories (skips ones already present; cvelistV5 is large)
cd /opt/opencve/opencve-3.0.0/docker && docker compose exec -T airflow-scheduler sh -c 'test -d /home/airflow/repositories/opencve-kb/.git || git clone https://github.com/opencve/opencve-kb.git /home/airflow/repositories/opencve-kb'
cd /opt/opencve/opencve-3.0.0/docker && docker compose exec -T airflow-scheduler sh -c 'test -d /home/airflow/repositories/opencve-nvd/.git || git clone https://github.com/opencve/opencve-nvd.git /home/airflow/repositories/opencve-nvd'
Cloning into '/home/airflow/repositories/opencve-nvd'...
Updating files: 100% (381947/381947), done.
cd /opt/opencve/opencve-3.0.0/docker && docker compose exec -T airflow-scheduler sh -c 'test -d /home/airflow/repositories/opencve-redhat/.git || git clone https://github.com/opencve/opencve-redhat.git /home/airflow/repositories/opencve-redhat'
Cloning into '/home/airflow/repositories/opencve-redhat'...
Updating files: 100% (54438/54438), done.
cd /opt/opencve/opencve-3.0.0/docker && docker compose exec -T airflow-scheduler sh -c 'test -d /home/airflow/repositories/cvelistV5/.git || git clone https://github.com/CVEProject/cvelistV5.git /home/airflow/repositories/cvelistV5'
Cloning into '/home/airflow/repositories/cvelistV5'...
Updating files: 100% (382001/382001), done.
cd /opt/opencve/opencve-3.0.0/docker && docker compose exec -T airflow-scheduler sh -c 'test -d /home/airflow/repositories/vulnrichment/.git || git clone https://github.com/cisagov/vulnrichment.git /home/airflow/repositories/vulnrichment'
Cloning into '/home/airflow/repositories/vulnrichment'...
Updating files: 100% (174899/174899), done.
==> KB repositories ready
make opencve-import-kb
==> Importing CVEs/vendors/products into the database (first run only, ~10-30 min)...
Parsing the OpenCVE KB repository (/app/repositories/opencve-kb)
Found 383153 CVEs, adding them in database...
Done in 508.341s
==> CVE import complete
Superuser created successfully.
INSERT 0 1
make opencve-unpause-dag
cd /opt/opencve/opencve-3.0.0/docker && docker compose exec -T airflow-scheduler airflow dags unpause opencve
/home/airflow/.local/lib/python3.11/site-packages/airflow/cli/commands/dag_command.py:48 UserWarning: Could not import graphviz. Rendering graph to the graphical format will not be possible.
No paused DAGs were found
==> OpenCVE Airflow DAG unpaused (hourly incremental sync)
make opencve-subscribe-vendors
cd /opt/opencve/opencve-3.0.0/docker && docker compose exec -T \
        -e OPENCVE_SUBSCRIBE_USER=leechienpang \
        -e OPENCVE_SUBSCRIBE_ORG=Lee \
        -e OPENCVE_SUBSCRIBE_PROJECT=ChienPang \
        -e OPENCVE_SUBSCRIBE_VENDORS="mariadb" \
        webserver python manage.py shell < opencve_subscribe.py
33 objects imported automatically (use -v 2 for details).

==> Project 'ChienPang' (org 'Lee') subscriptions: {'vendors': ['mariadb'], 'products': []}
==> OpenCVE web UI: http://localhost:8090
==> Airflow UI:     http://localhost:8091 (airflow/airflow)
==> Admin login: leechienpang / leechienpang-20260824
==> Org 'Lee' / project 'ChienPang' subscribed to: mariadb
==> Notification links (Slack/email) point to: http://10.32.200.11:8090

It’s worth taking your time enjoying the main course — midway through, you can already see the services come up.

1
2
3
4
5
6
7
8
[root@opencve-host]# docker ps | grep opencve
b7e15803d906   opencve-airflow-worker        "/usr/bin/dumb-init …"   3 minutes ago   Up 3 minutes (healthy)   8080/tcp                                                                                                                          opencve-airflow-worker-1
b84ced7f491b   opencve-airflow-webserver     "/usr/bin/dumb-init …"   3 minutes ago   Up 3 minutes (healthy)   0.0.0.0:8091->8080/tcp, :::8091->8080/tcp                                                                                         opencve-airflow-webserver-1
9d4f3e1d1686   opencve-airflow-scheduler     "/usr/bin/dumb-init …"   3 minutes ago   Up 3 minutes (healthy)   8080/tcp                                                                                                                          opencve-airflow-scheduler-1
d3579cce5f05   postgres:15                   "docker-entrypoint.s…"   3 minutes ago   Up 3 minutes (healthy)   5432/tcp                                                                                                                          opencve-postgres-1
02350c56e1cd   opencve-webserver             "gunicorn opencve.ws…"   3 minutes ago   Up 3 minutes             8000/tcp                                                                                                                          opencve-webserver-1
90a87423488a   nginx:trixie                  "/docker-entrypoint.…"   3 minutes ago   Up 3 minutes             0.0.0.0:8090->80/tcp, :::8090->80/tcp                                                                                             opencve-nginx-1
1cca845c6131   redis/redis-stack:latest      "/entrypoint.sh"         3 minutes ago   Up 3 minutes (healthy)   6379/tcp, 8001/tcp                                                                                                                opencve-redis-1

That means the UI is available too:

OpenCVE login page

A couple of things worth noting: opencve-clone-kb clones the repositories and knowledge-base data, while opencve-import-kb imports that data into the freshly deployed OpenCVE instance. Next, we need to unpause the DAG (opencve-unpause-dag), which is managed by another core service, Airflow (port 8091). Log in with the default credentials: airflow / airflow.

Airflow login page

Airflow unpause-dag.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[root@opencve-host opencve]# make opencve-unpause-dag
cd /opt/opencve/opencve-3.1.0/docker && docker compose exec -T airflow-scheduler airflow dags unpause opencve
/home/airflow/.local/lib/python3.12/site-packages/airflow/cli/commands/dag_command.py:48 UserWarning: Could not import graphviz. Rendering graph to the graphical format will not be possible.
dag_id  | is_paused
========+==========
opencve | False

==> OpenCVE Airflow DAG unpaused (hourly incremental sync)
make opencve-subscribe-vendors
cd /opt/opencve/opencve-3.1.0/docker && docker compose exec -T \
        -e OPENCVE_SUBSCRIBE_USER=leechienpang \
        -e OPENCVE_SUBSCRIBE_ORG=Lee \
        -e OPENCVE_SUBSCRIBE_PROJECT=ChienPang \
        -e OPENCVE_SUBSCRIBE_VENDORS="mariadb" \
        webserver python manage.py shell < opencve_subscribe.py
34 objects imported automatically (use -v 2 for details).

==> Project 'ChienPang' (org 'Lee') subscriptions: {'vendors': ['mariadb'], 'products': []}
==> OpenCVE web UI: http://localhost:8090
==> Airflow UI:     http://localhost:8091 (airflow/airflow)
==> Admin login: leechienpang / leechienpang-20260824
==> Org 'Lee' / project 'ChienPang' subscribed to: mariadb
==> Notification links (Slack/email) point to: http://10.32.200.11:8090

These two targets take the longest — how long mostly depends on your network bandwidth:

  • make opencve-clone-kb
  • make opencve-import-kb

opencve-loggedin page

The last part, which I did manually, was scheduling a report and alert in the “Automations” tab.

Automations page

And here’s the payoff: a single automated pipeline that deploys the latest OpenCVE release, updates repositories and KB, imports data, unpauses DAG, and applies configurable account settings — standing up a self-hosted OpenCVE that catches critical CVEs and drops them straight into Slack.

OpenCVE Slack notification

This post is licensed under CC BY 4.0 by the author.

Trending Tags