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:
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.
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-kbmake opencve-import-kb
The last part, which I did manually, was scheduling a report and alert in the “Automations” tab.
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.





