Platform for monitoring third-party software components (OS packages, runtimes, infrastructure software) for known vulnerabilities and development health.
oss-monitor is designed for tracking environment-level software β components that are not part of your product's source code directly, but are still subject to security control: web servers, databases, container runtimes, OS packages, and similar.
- π Add components by name, version, type (library or product), and ecosystem
- π Auto-generate identifiers: PURL for libraries, CPE for products
- π‘ Query public vulnerability databases: OSV.dev and NVD
- πΎ Store components and vulnerabilities in a local SQLite database
- β Mark vulnerabilities as false positives with reasoning
- π Daily automated refresh at 03:00 UTC
- βοΈ Background jobs β full refreshes run outside the request, with live progress and a result you can come back to
- π¦ Rate limiting β per-client budget on the endpoints that spend the NVD quota
- π·οΈ Tag components (e.g.
prod,staging,db-server) and filter by tag - π€ Assign an owner per component and filter by it
- π¬ Notification routing β send each component's alerts to the channel of whoever owns it
- π Notes per component
- π Dashboard with severity breakdown and top vulnerable components
- β¬οΈ Fix versions β every finding carries the version that clears it, resolved per release branch
- π― One upgrade recommendation per component: "upgrade to 2.25.4 β clears 7 of 7", with smaller intermediate steps
- π― CVE-PaaS integration β enrich every CVE with an exploitation-aware priority: EPSS, CISA/VulnCheck KEV, public PoCs and Nuclei templates
- π¨ Alerts on KEV listings regardless of CVSS severity β a medium CVE under active exploitation outranks an unexploited critical one
- β¬οΈ Escalation alerts when a CVE you already track gains a PoC or enters KEV
- π OpenSSF Scorecard β auto-fetch development health score (0β10) when a GitHub repo URL is provided
- π Evidence & Links β attach analyst reports, incident links, VirusTotal reports, CVE discussions, and audit reports to any component
- π Webhook notifications β Slack, Telegram, or any HTTP endpoint
- π¨ Immediate alerts for new critical/high CVEs discovered during refresh
- π Daily digest for Scorecard score failures and stale/abandoned components
docker-compose up --build| Service | URL |
|---|---|
| Web UI (Streamlit) | http://localhost:8501 |
| REST API + Swagger | http://localhost:8000/docs |
Settings are managed via the Settings page in the UI or via the API (PUT /settings).
| Setting | Default | Description |
|---|---|---|
webhook_url |
null |
Webhook endpoint (Slack, Telegram, custom HTTP) |
notify_on_critical |
true |
Send immediate alert for new critical CVEs |
notify_on_high |
false |
Send immediate alert for new high CVEs |
scorecard_min_score |
5.0 |
Minimum acceptable OpenSSF Scorecard score |
stale_days_threshold |
730 |
Days without commits before a component is flagged |
notify_on_scorecard_fail |
true |
Include Scorecard failures in daily digest |
notify_on_stale |
true |
Include stale components in daily digest |
cvepaas_enabled |
false |
Enable CVE-PaaS prioritization |
cvepaas_url |
null |
CVE-PaaS base URL (falls back to CVEPAAS_URL env var) |
notify_on_kev |
true |
Immediate alert when a CVE is listed in CISA/VulnCheck KEV |
notify_on_priority_critical |
false |
Immediate alert on CVE-PaaS priority Critical |
epss_alert_threshold |
0.0 |
Alert when EPSS reaches this value (0 = off) |
notification_routes |
null |
Per-owner / per-tag webhook routing (see below) |
Immediate CVE alert:
{
"event": "new_vulnerability",
"text": "π΄ New CRITICAL vulnerability detected",
"component": "nginx 1.23.0 [prod,web]",
"cve_id": "CVE-2024-XXXXX",
"severity": "critical"
}Daily digest:
{
"event": "daily_digest",
"text": "π Daily QG digest β 2 issue(s) found",
"issues": [
{ "component": "nginx 1.23.0", "reason": "Scorecard score 3.5/10 below threshold 5.0", "type": "scorecard_fail" },
{ "component": "redis 6.0.0", "reason": "No commits for 800 days (threshold: 730)", "type": "stale" }
]
}"You have 7 CVEs" is a fact. "Upgrade to 2.25.4 and 7 of them go away" is a task. Every finding
carries fixed_version when the advisory publishes one, and each component gets a single
recommendation built from them.
Resolving the fix version is not simply "the lowest fix in the advisory". Log4Shell's follow-up
lists log4j-core under both [2.13.0, 2.16.0) and [0, 2.12.2); a 2.14.1 install must be told
2.16.0, because 2.12.2 is a backport of a branch it is not on β recommending it would be a
downgrade that fixes nothing. The resolver picks the fix belonging to the range the installed
version actually falls in.
β¬οΈ Upgrade to 2.25.4 β clears 7 of 7 finding(s)
Smaller upgrade steps
2.15.0 β clears 1 of 7 2.17.1 β clears 4 of 7
2.16.0 β clears 2 of 7 2.25.3 β clears 5 of 7
2.17.0 β clears 3 of 7
The steps matter when the full jump is blocked by a breaking change β you can still see what a smaller bump buys. Findings marked as false positives are excluded, so a suppressed CVE never drags the recommended version up.
| Source | Fix data |
|---|---|
| OSV (libraries) | affected[].ranges[].events[].fixed, resolved per branch |
| NVD (products) | versionEndExcluding only |
NVD often has none: versionEndIncluding means the named version is still vulnerable and the fix
is some later, unnamed release, and an exact CPE match carries no range at all. Both yield no fix
version rather than a guess, which is why libraries have far better coverage than products.
One webhook for the whole inventory means every team gets every alert, which is how alert channels become noise nobody reads. Set an owner on a component and route its alerts accordingly:
[
{"match": {"owner": "platform"}, "webhook_url": "https://hooks.slack.com/services/AAA"},
{"match": {"tag": "prod"}, "webhook_url": "https://hooks.slack.com/services/ONCALL"},
{"match": {"owner": "data", "tag": "db"}, "webhook_url": "https://hooks.slack.com/services/DATA"}
]- Match on
owner, ontag, or on both β when both are present, both must hold. - Every matching route receives the alert, so a
prodcomponent owned byplatformreaches the first two channels above. - A component matching no route falls back to the global
webhook_url, so adding routes never silently drops alerts for anyone. - The daily digest is split per channel: each team gets a digest of its own components.
Configure it on the Settings page or via PUT /settings with a notification_routes array.
Alert payloads carry owner and fixed_version alongside the existing fields.
NVD allows 5 requests / 30 s anonymously and 50 / 30 s with a key. A nightly refresh over a few
dozen products exceeds the anonymous budget, and NVD answers with 403/429 rather than an error a
caller would notice β the component simply comes back with no vulnerabilities, which looks exactly
like a clean result.
Requests are paced process-wide and retried with exponential backoff and jitter (honouring
Retry-After) either way. Set a free API key
to make it ten times faster:
NVD_API_KEY=your-key docker compose up| Variable | Default | Description |
|---|---|---|
NVD_API_KEY |
(not set) | Raises the NVD budget from 5 to 50 requests / 30 s |
NVD_MIN_INTERVAL |
6.5 (0.7 with a key) |
Seconds between NVD requests; raise it if still throttled |
Refreshing a whole inventory takes minutes β NVD alone paces requests at 6.5 s apiece without an API key β which is far longer than a browser, a proxy or a client library will hold a connection open. So the long operations return a job instead of a result:
| Endpoint | Behaviour |
|---|---|
POST /components/refresh_all |
202 + job record; 409 if one is already running |
POST /vulnerabilities/enrich |
202 + job record; 409 if one is already running |
GET /jobs |
Recent jobs, newest first (?kind=, ?limit=) |
GET /jobs/{id} |
One job: status, progress, result or error |
JOB=$(curl -sX POST localhost:8000/components/refresh_all | jq -r .id)
curl -s localhost:8000/jobs/$JOB | jq '{status, processed, total, detail}'
# { "status": "running", "processed": 12, "total": 40, "detail": "nginx 1.24.0" }The UI shows a live progress bar on the Components and Dashboard pages and does not block while the job runs.
Three properties worth knowing:
- One job per kind at a time. Two concurrent full refreshes would double the load on NVD for no
benefit, so the second request is refused with
409naming the job already running. - A failing component does not abandon the run. Its error is recorded in the job's
result.failedlist and the refresh continues; previously one unreachable source cost you every component after it. - A restart fails interrupted jobs. Jobs are asyncio tasks in this process, so anything still marked running after a restart died with it. Startup marks those failed β leaving them would both mislead the UI and block that kind forever.
Jobs deliberately do not use a broker. The app is a single uvicorn worker over SQLite; Celery and Redis would cost more than they buy here. The consequence is that jobs do not survive a restart and do not spread across replicas β if you ever run more than one instance, that is the thing to revisit.
API authentication is off by default, and /analyze, /generate_identifier and POST /components
all reach NVD and OSV on the caller's behalf. Left unbounded they let anyone who can reach the port
burn this deployment's NVD budget β and because NVD answers an over-budget client with 403 rather
than an error, that degrades the nightly refresh into silently returning no vulnerabilities.
Those three endpoints share a per-client budget, so alternating between them does not double the allowance. Read endpoints are never limited; the limiter protects the external quota, not the database.
| Variable | Default | Description |
|---|---|---|
OSS_MONITOR_RATE_LIMIT |
30 |
Requests per minute per client. 0 disables it |
TRUSTED_PROXY_COUNT |
0 |
Reverse-proxy hops in front of the service |
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429
also carries Retry-After.
X-Forwarded-For is only read when TRUSTED_PROXY_COUNT is set, and the client is taken that many
hops from the right. The header is client-controlled: trusting it on a directly-exposed service
would let anyone reset their own counter on every request, and counting from the right is what makes
the value unspoofable, since a client can prepend entries but not remove the ones proxies append.
The counter is in-process. With several uvicorn workers or replicas each keeps its own and the effective limit is N times the configured one. Put the limit on the reverse proxy if you need a cluster-wide budget.
Schema changes are Alembic migrations under
backend/migrations/versions, applied automatically on startup β no manual step on upgrade.
Databases created before Alembic are adopted automatically: a one-time idempotent bridge brings them to the baseline shape, stamps them, and from then on they follow the normal path. Existing data is preserved.
To add a migration after changing db/models.py:
cd backend
alembic revision --autogenerate -m "describe the change"
# review the generated file, then it applies on next startupSQLite cannot ALTER most things in place, so migrations run in batch mode (Alembic rebuilds the
table), which is what makes column drops and type changes possible at all.
CVSS ranks theoretical impact. CVE-PaaS ranks what is
actually being exploited β it combines CVSS with EPSS, the CISA and VulnCheck KEV catalogues,
public PoCs and Nuclei templates into a single Priority verdict.
With the integration on, every CVE in the inventory carries that verdict, the dashboard breaks the inventory down by priority instead of severity alone, and the Quality Gate can alert on exploitation signals that CVSS severity rules would miss entirely.
Requires CVE-PaaS 1.4.0 or newer. Before that a KEV listing never reached the verdict, so a CVE under active exploitation but without a public PoC came back under-prioritised β and
notify_on_kev, the default-on rule here, would never fire for it. Older releases still work; they just report less.
CVE-PaaS needs an API key from ProjectDiscovery. It ships as an opt-in compose profile:
PDCP_API_KEY=<your_pdcp_key> docker compose --profile cvepaas up --buildThen open Settings β CVE-PaaS Prioritization, enable the integration, and set the base URL to
http://cve-paas:8000. Test connection probes /healthz, Backfill priorities looks up every
stored CVE that has no verdict yet.
Running CVE-PaaS elsewhere works the same β point cvepaas_url at it. If that instance is protected
with CVE_PAAS_API_KEY, set the matching key on the oss-monitor backend:
CVEPAAS_API_KEY=<the_same_secret> docker compose upThe key is read from the environment only and is never written to the settings database. The base URL likewise comes from stored settings or the environment, never from a request body β the same rule that keeps the webhook endpoints free of SSRF.
| Field | Meaning |
|---|---|
priority |
Critical / High / Medium / Low / Info / Undefined |
epss_score |
Exploit-prediction score, 0.0β1.0 |
is_kev |
Listed in CISA or VulnCheck KEV |
is_poc |
A public proof-of-concept exists |
is_template |
A Nuclei template exists |
cvepaas_links |
Direct links to the PoC, the Nuclei search and the KEV entry |
Only real CVE-YYYY-NNNNN identifiers are sent. OSV keys library advisories by GHSA-/PYSEC-/GO-
ID and carries the CVE in aliases, so those aliases are stored and used for the lookup β otherwise
no library component could ever be prioritized. An advisory with no CVE alias is simply skipped.
| Endpoint | Purpose |
|---|---|
POST /components/{id}/enrich |
Prioritize one component's CVEs (?force=true re-fetches all) |
POST /vulnerabilities/enrich |
Backfill priorities across the whole inventory |
POST /settings/test-cvepaas |
Probe the configured instance |
The daily 03:00 UTC refresh re-checks every CVE with force=true, so a PoC published overnight for a
CVE you have been carrying for months shows up as an escalation alert:
{
"event": "priority_escalation",
"text": "β¬οΈ *Exploitation status escalated* β CVE-2024-1337 added to KEV",
"component": "nginx 1.24.0 [prod,web]",
"cve_id": "CVE-2024-1337",
"previous_priority": "Medium",
"priority": "Critical",
"epss_score": 0.87,
"is_kev": true
}Cost note: enrichment is bounded by vulnx, roughly
1.4 s + 0.7 s per CVEper batch of 50. The CVE-PaaS cache absorbs most of it β set a generousCACHE_TTL_HOURS(the compose profile defaults to 168) and the second run over a stable inventory is close to free.
Set repo_url when adding a component (or via Edit) to enable automatic Scorecard checks:
https://github.com/nginx/nginx
Score and per-check details are fetched automatically on add and refreshed daily.
Manual refresh: POST /components/{id}/scorecard.
Attach supporting material to any component via Add Evidence in the UI or POST /components/{id}/evidence:
| Type | Use case |
|---|---|
analyst_report |
Internal or third-party security analysis |
incident_link |
Link to a public incident or GitHub issue |
virustotal |
VirusTotal report URL |
cve_discussion |
Blog post or HackerNews thread about a CVE |
audit_report |
Penetration test or compliance audit result |
other |
Anything else |
cd backend
pip install -r requirements-test.txt
pytest237 tests covering vulnerability analysis, API endpoints, Scorecard, Quality Gate, Evidence, Settings, bulk import, CVSS scoring, CVE-PaaS prioritization, version comparison and fix resolution, notification routing, NVD rate-limit handling, schema migrations, background jobs, and client rate limiting.
Import multiple components at once from a JSON file via the Import page in the UI or POST /components/import.
An example file is available at examples/import.json.
[
{
"type": "product",
"name": "nginx",
"version": "1.24.0",
"tags": "prod,web",
"repo_url": "https://github.com/nginx/nginx"
},
{
"type": "library",
"name": "lodash",
"version": "4.17.21",
"ecosystem": "npm",
"tags": "frontend"
}
]Required fields: type, name, version (+ ecosystem for libraries).
Optional: tags, owner, notes, repo_url, distrib_url, identifier_override.
Limit: 200 components per request. Duplicates are skipped automatically.
Disabled by default. To enable, set the OSS_MONITOR_API_KEY environment variable:
OSS_MONITOR_API_KEY=your-secret-key docker-compose upThen pass the key via the X-API-Key header:
curl -H "X-API-Key: your-secret-key" http://localhost:8000/components- GitHub release download + SHA-256 checksum verification
- SBOM (CycloneDX/SPDX) import and SCA analysis
- Background job for bulk import (
POST /components/importis still synchronous, and 200 components through NVD pacing is far longer than a request should live)