vulnmirror 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vulnmirror-0.1.0/LICENSE +5 -0
- vulnmirror-0.1.0/PKG-INFO +218 -0
- vulnmirror-0.1.0/README.md +193 -0
- vulnmirror-0.1.0/pyproject.toml +71 -0
- vulnmirror-0.1.0/pyproject.toml.orig +52 -0
- vulnmirror-0.1.0/src/vulnmirror/__init__.py +1 -0
- vulnmirror-0.1.0/src/vulnmirror/__main__.py +5 -0
- vulnmirror-0.1.0/src/vulnmirror/build.py +105 -0
- vulnmirror-0.1.0/src/vulnmirror/cases.py +256 -0
- vulnmirror-0.1.0/src/vulnmirror/cli.py +272 -0
- vulnmirror-0.1.0/src/vulnmirror/config.py +96 -0
- vulnmirror-0.1.0/src/vulnmirror/fetch.py +137 -0
- vulnmirror-0.1.0/src/vulnmirror/ghsa.py +176 -0
- vulnmirror-0.1.0/src/vulnmirror/net.py +92 -0
- vulnmirror-0.1.0/src/vulnmirror/query.py +95 -0
- vulnmirror-0.1.0/src/vulnmirror/records.py +236 -0
- vulnmirror-0.1.0/src/vulnmirror/sql/indexes.sql +21 -0
- vulnmirror-0.1.0/src/vulnmirror/sql/schema.sql +59 -0
- vulnmirror-0.1.0/src/vulnmirror/update.py +142 -0
- vulnmirror-0.1.0/tests/conftest.py +248 -0
- vulnmirror-0.1.0/tests/fixtures/cvegenie_large_ids.txt +844 -0
- vulnmirror-0.1.0/tests/test_build_update.py +229 -0
- vulnmirror-0.1.0/tests/test_cases.py +205 -0
- vulnmirror-0.1.0/tests/test_cli.py +40 -0
- vulnmirror-0.1.0/tests/test_config.py +34 -0
- vulnmirror-0.1.0/tests/test_regression.py +85 -0
vulnmirror-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Copyright (C) 2026 by Nemo Xiong
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
|
|
4
|
+
|
|
5
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vulnmirror
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A local, incrementally updated SQLite mirror of CVE Program, NVD, CISA KEV and GitHub Advisory Database metadata.
|
|
5
|
+
Keywords: cve,nvd,kev,ghsa,osv,vulnerability,sqlite,mirror
|
|
6
|
+
Author: Nemo Xiong
|
|
7
|
+
Author-email: Nemo Xiong <nemo@anzupop.com>
|
|
8
|
+
License-Expression: 0BSD
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Intended Audience :: Information Technology
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Requires-Dist: certifi>=2024.2.2
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# vulnmirror
|
|
27
|
+
|
|
28
|
+
A local, incrementally updated SQLite mirror of public vulnerability metadata:
|
|
29
|
+
|
|
30
|
+
| Source | What it contributes | Incremental channel |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| CVE Program [`cvelistV5`](https://github.com/CVEProject/cvelistV5) | the authoritative CVE record: state, dates, CNA, description, CNA/ADP affected vendor + product, references with tags, CWE, CVSS | `cves/deltaLog.json` (about 30 days) |
|
|
33
|
+
| [NVD](https://nvd.nist.gov) CVE API 2.0 feeds | NVD analysis status, CPE configurations, NVD reference tags, NVD CWE and CVSS | `modified` + `recent` feeds (8 days) |
|
|
34
|
+
| [CISA KEV](https://www.cisa.gov/known-exploited-vulnerabilities-catalog) | known-exploited flag, date added | full reload (small) |
|
|
35
|
+
| [GitHub Advisory Database](https://github.com/github/advisory-database) (OSV format) | package ecosystem and name, affected and fixed versions, CVE aliases, CWE, severity | `git diff` between synced commits |
|
|
36
|
+
|
|
37
|
+
Everything lands in one SQLite file you can query with plain SQL. Every row records which
|
|
38
|
+
provider supplied it (`cna`, `adp:<name>`, `nvd`), so CNA-reported and NVD-enriched facts
|
|
39
|
+
are never silently mixed.
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
- Python 3.11 or newer, `curl` and `git` on `PATH`
|
|
44
|
+
- Disk: about 3.2 GB of raw downloads and a 2.8 GB database (measured 2026-09-24)
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
uv tool install vulnmirror # or: pipx install vulnmirror
|
|
50
|
+
# from a checkout:
|
|
51
|
+
uv tool install --editable .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or run it without installing:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
uvx vulnmirror status # from PyPI, in a throwaway environment
|
|
58
|
+
uv run vulnmirror status # inside a checkout
|
|
59
|
+
uv run --project /path/to/vulnmirror vulnmirror status # a checkout, from any directory
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Every form reads the same data directory (next section), so they can be mixed freely.
|
|
63
|
+
|
|
64
|
+
## Quick start
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
vulnmirror init # download everything, clone the advisory database, build, update
|
|
68
|
+
vulnmirror status # sync markers, their age, row counts
|
|
69
|
+
vulnmirror update # afterwards: incremental update, about a minute
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`init` retries through network outages, and every download resumes where it stopped, so
|
|
73
|
+
it is safe to interrupt. A first run took roughly half an hour on 2026-09-23: the NVD feeds
|
|
74
|
+
took about 13 minutes (NVD serves them slowly; they are fetched in parallel), the rest went
|
|
75
|
+
to cloning the advisory database and a build of under 3 minutes.
|
|
76
|
+
|
|
77
|
+
## Where the data lives
|
|
78
|
+
|
|
79
|
+
The data directory ("home") is resolved in this order:
|
|
80
|
+
|
|
81
|
+
1. `--home PATH`
|
|
82
|
+
2. `$VULNMIRROR_HOME`
|
|
83
|
+
3. `home = "..."` in `$XDG_CONFIG_HOME/vulnmirror/config.toml` (default `~/.config/vulnmirror/config.toml`)
|
|
84
|
+
4. `$XDG_DATA_HOME/vulnmirror` (default `~/.local/share/vulnmirror`)
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
vulnmirror config set-home ~/datasets/vulnmirror
|
|
88
|
+
vulnmirror config show
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Layout inside the home: `vulnmirror.sqlite`, `MANIFEST.json` (the raw snapshot), and
|
|
92
|
+
`raw/` (`cvelistV5/`, `nvd/`, `kev/`, `ghsa/advisory-database/`, `cases/`).
|
|
93
|
+
|
|
94
|
+
## Keeping it current
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
vulnmirror update # all sources
|
|
98
|
+
vulnmirror update --only nvd,ghsa # a subset
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Each source is applied in its own transaction. A source that fails is rolled back and keeps
|
|
102
|
+
its sync marker, so running `update` again retries the same window; running it twice in a
|
|
103
|
+
row changes nothing. Run it at least weekly:
|
|
104
|
+
|
|
105
|
+
- NVD older than 7 days: `update` reloads the NVD tables from the yearly feeds by itself.
|
|
106
|
+
- cvelistV5 older than the delta log (about 30 days): `update` refuses; run `vulnmirror fetch`
|
|
107
|
+
and `vulnmirror build`, then `update`.
|
|
108
|
+
- The advisory-database clone missing or re-cloned: `update` clones it again and reloads the
|
|
109
|
+
GHSA tables when the recorded commit is gone.
|
|
110
|
+
|
|
111
|
+
A full rebuild (`vulnmirror fetch && vulnmirror build`) writes to `vulnmirror.sqlite.building`
|
|
112
|
+
and renames it into place when done, so queries keep working meanwhile.
|
|
113
|
+
|
|
114
|
+
## Individual records: `vulnmirror get`
|
|
115
|
+
|
|
116
|
+
Fetch single records by identifier or URL, one at a time or from lists:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
vulnmirror get CVE-2024-3094 # CVE record + NVD record
|
|
120
|
+
vulnmirror get --type nvd CVE-2024-3094 # NVD record only
|
|
121
|
+
vulnmirror get https://nvd.nist.gov/vuln/detail/CVE-2021-44228
|
|
122
|
+
vulnmirror get https://github.com/advisories/GHSA-jfh8-c2jp-5v3q
|
|
123
|
+
vulnmirror get -f refs.txt # one reference per line, '#' comments
|
|
124
|
+
cat refs.txt | vulnmirror get -f - # from stdin
|
|
125
|
+
vulnmirror get -f refs.txt --ingest # also upsert into the database
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
| Reference | Record type |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `CVE-YYYY-NNNN` | `cve` and `nvd` (narrow with `--type`) |
|
|
131
|
+
| `GHSA-xxxx-xxxx-xxxx` | `ghsa` |
|
|
132
|
+
| `cve.org`, `cve.mitre.org`, cvelistV5 file URLs | `cve` |
|
|
133
|
+
| `nvd.nist.gov` pages and NVD API URLs | `nvd` |
|
|
134
|
+
| GitHub advisory pages (global or repository), `osv.dev`, `api.osv.dev`, advisory-database file URLs | `ghsa` |
|
|
135
|
+
| any other URL that contains exactly one CVE or GHSA identifier | inferred from the identifier |
|
|
136
|
+
|
|
137
|
+
Records are stored as `raw/cases/<type>/<ID>.json`. An existing file is kept unless you pass
|
|
138
|
+
`--force`, so re-running a long list only fetches what is missing. Every download is logged
|
|
139
|
+
with its source URL in `raw/cases/index.jsonl`.
|
|
140
|
+
|
|
141
|
+
- `cve` comes from cvelistV5 on GitHub.
|
|
142
|
+
- `nvd` comes from the NVD CVE API 2.0. Requests are spaced 6.5 s apart; set `NVD_API_KEY` to
|
|
143
|
+
use the higher limit (0.7 s).
|
|
144
|
+
- `ghsa` is the original file from github/advisory-database, located through the OSV API. If
|
|
145
|
+
the original cannot be fetched, the OSV copy is stored instead and the log says so.
|
|
146
|
+
|
|
147
|
+
## Querying
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
vulnmirror sql "SELECT kind, count(*) FROM reference GROUP BY kind"
|
|
151
|
+
vulnmirror sql "SELECT * FROM kev LIMIT 5" --format json
|
|
152
|
+
vulnmirror filter --vendor examplevendor --from 2024-06-01 --to 2025-05-31 --format csv -o hits.csv
|
|
153
|
+
vulnmirror filter --cpe-part h --cpe-scope any --from 2024-06-01 --to 2025-05-31 --count
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`filter` searches the CNA/ADP `affected` table and the NVD `nvd_cpe` table together; the
|
|
157
|
+
`sources` column says which one matched. Output formats: `tsv` (default), `csv`, `json`, `jsonl`.
|
|
158
|
+
|
|
159
|
+
### Tables
|
|
160
|
+
|
|
161
|
+
| Table | Grain | Notes |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `cve` | one row per CVE ID | from cvelistV5; `state` is `PUBLISHED` or `REJECTED` |
|
|
164
|
+
| `nvd` | one row per CVE ID known to NVD | `status` is NVD's analysis status |
|
|
165
|
+
| `affected` | CVE × provider × product | vendor and product are free text as supplied |
|
|
166
|
+
| `reference` | CVE × provider × URL | `kind` is derived from the URL; `tags` are the provider's own vocabulary |
|
|
167
|
+
| `weakness`, `metric` | CVE × provider × CWE / CVSS version | |
|
|
168
|
+
| `nvd_cpe` | CVE × CPE match | `part` is `a` / `o` / `h`; `vulnerable` = 1 for vulnerable matches |
|
|
169
|
+
| `kev` | one row per KEV entry | |
|
|
170
|
+
| `ghsa` | one row per advisory | `reviewed` = 1 for GitHub-reviewed advisories |
|
|
171
|
+
| `ghsa_alias` | advisory × alias | join to `cve.id` on CVE aliases |
|
|
172
|
+
| `ghsa_affected` | advisory × package | first `introduced` / `fixed` / `last_affected` event; full ranges as JSON |
|
|
173
|
+
| `ghsa_reference`, `ghsa_cwe` | advisory × URL / CWE | |
|
|
174
|
+
| `snapshot` | key / value | source snapshots and sync markers |
|
|
175
|
+
|
|
176
|
+
### Before you count
|
|
177
|
+
|
|
178
|
+
- **NVD marks device hardware as a non-vulnerable platform.** A device CVE is usually modelled as
|
|
179
|
+
vulnerable firmware (`o:*_firmware`, vulnerable = 1) running on hardware (`h`, vulnerable = 0).
|
|
180
|
+
Use `--cpe-scope any` for hardware queries.
|
|
181
|
+
- **NVD CPE coverage is incomplete** for recent CVEs, so CPE-based counts are lower bounds; the
|
|
182
|
+
CNA `affected` table covers records NVD has not analysed yet.
|
|
183
|
+
- **CNA vendor fields are often `n/a`**, with the vendor only in NVD's CPEs. Query both tables
|
|
184
|
+
(`filter` does).
|
|
185
|
+
- **There is no device-type field.** Selecting CVEs for one kind of device means choosing vendor
|
|
186
|
+
and product criteria and checking a sample by hand.
|
|
187
|
+
- **Dates differ by source.** `cve.date_published`, `nvd.published` and `ghsa.published` can be
|
|
188
|
+
hours to days apart; say which one a count uses.
|
|
189
|
+
- **Only GitHub-reviewed advisories** reliably carry package ecosystems and version ranges.
|
|
190
|
+
|
|
191
|
+
## Data terms
|
|
192
|
+
|
|
193
|
+
vulnmirror downloads data; it does not redistribute it. Each source has its own terms:
|
|
194
|
+
|
|
195
|
+
- cvelistV5: "You may search, download, and use the content hosted in this repository, per the
|
|
196
|
+
[CVE Program Terms of Use](https://www.cve.org/Legal/TermsOfUse)" (cvelistV5 README).
|
|
197
|
+
- GitHub Advisory Database: [CC BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md);
|
|
198
|
+
attribute GitHub when you publish derived data.
|
|
199
|
+
- NVD and CISA KEV: see the terms published on their sites.
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
vulnmirror is released under the [BSD Zero Clause License](LICENSE) (SPDX `0BSD`). The data it
|
|
204
|
+
downloads is not covered by this license; see [Data terms](#data-terms).
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
uv sync # environment with dev dependencies
|
|
210
|
+
uv run pytest # unit and integration tests on synthetic data, no network
|
|
211
|
+
VULNMIRROR_REGRESSION=1 uv run pytest -m regression # checks a real mirror against reference counts
|
|
212
|
+
uv run ruff check src tests && uv run ruff format --check src tests
|
|
213
|
+
uv build # sdist and wheel in dist/
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The regression tests compare a real mirror with counts computed independently from the NVD API
|
|
217
|
+
on 2026-09-23 (for example 42,612 non-rejected CVEs published 2024-06-01 to 2025-05-31). NVD
|
|
218
|
+
re-analyses records over time, so these counts carry a tolerance.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# vulnmirror
|
|
2
|
+
|
|
3
|
+
A local, incrementally updated SQLite mirror of public vulnerability metadata:
|
|
4
|
+
|
|
5
|
+
| Source | What it contributes | Incremental channel |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| CVE Program [`cvelistV5`](https://github.com/CVEProject/cvelistV5) | the authoritative CVE record: state, dates, CNA, description, CNA/ADP affected vendor + product, references with tags, CWE, CVSS | `cves/deltaLog.json` (about 30 days) |
|
|
8
|
+
| [NVD](https://nvd.nist.gov) CVE API 2.0 feeds | NVD analysis status, CPE configurations, NVD reference tags, NVD CWE and CVSS | `modified` + `recent` feeds (8 days) |
|
|
9
|
+
| [CISA KEV](https://www.cisa.gov/known-exploited-vulnerabilities-catalog) | known-exploited flag, date added | full reload (small) |
|
|
10
|
+
| [GitHub Advisory Database](https://github.com/github/advisory-database) (OSV format) | package ecosystem and name, affected and fixed versions, CVE aliases, CWE, severity | `git diff` between synced commits |
|
|
11
|
+
|
|
12
|
+
Everything lands in one SQLite file you can query with plain SQL. Every row records which
|
|
13
|
+
provider supplied it (`cna`, `adp:<name>`, `nvd`), so CNA-reported and NVD-enriched facts
|
|
14
|
+
are never silently mixed.
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Python 3.11 or newer, `curl` and `git` on `PATH`
|
|
19
|
+
- Disk: about 3.2 GB of raw downloads and a 2.8 GB database (measured 2026-09-24)
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
uv tool install vulnmirror # or: pipx install vulnmirror
|
|
25
|
+
# from a checkout:
|
|
26
|
+
uv tool install --editable .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or run it without installing:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
uvx vulnmirror status # from PyPI, in a throwaway environment
|
|
33
|
+
uv run vulnmirror status # inside a checkout
|
|
34
|
+
uv run --project /path/to/vulnmirror vulnmirror status # a checkout, from any directory
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Every form reads the same data directory (next section), so they can be mixed freely.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
vulnmirror init # download everything, clone the advisory database, build, update
|
|
43
|
+
vulnmirror status # sync markers, their age, row counts
|
|
44
|
+
vulnmirror update # afterwards: incremental update, about a minute
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`init` retries through network outages, and every download resumes where it stopped, so
|
|
48
|
+
it is safe to interrupt. A first run took roughly half an hour on 2026-09-23: the NVD feeds
|
|
49
|
+
took about 13 minutes (NVD serves them slowly; they are fetched in parallel), the rest went
|
|
50
|
+
to cloning the advisory database and a build of under 3 minutes.
|
|
51
|
+
|
|
52
|
+
## Where the data lives
|
|
53
|
+
|
|
54
|
+
The data directory ("home") is resolved in this order:
|
|
55
|
+
|
|
56
|
+
1. `--home PATH`
|
|
57
|
+
2. `$VULNMIRROR_HOME`
|
|
58
|
+
3. `home = "..."` in `$XDG_CONFIG_HOME/vulnmirror/config.toml` (default `~/.config/vulnmirror/config.toml`)
|
|
59
|
+
4. `$XDG_DATA_HOME/vulnmirror` (default `~/.local/share/vulnmirror`)
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
vulnmirror config set-home ~/datasets/vulnmirror
|
|
63
|
+
vulnmirror config show
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Layout inside the home: `vulnmirror.sqlite`, `MANIFEST.json` (the raw snapshot), and
|
|
67
|
+
`raw/` (`cvelistV5/`, `nvd/`, `kev/`, `ghsa/advisory-database/`, `cases/`).
|
|
68
|
+
|
|
69
|
+
## Keeping it current
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
vulnmirror update # all sources
|
|
73
|
+
vulnmirror update --only nvd,ghsa # a subset
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Each source is applied in its own transaction. A source that fails is rolled back and keeps
|
|
77
|
+
its sync marker, so running `update` again retries the same window; running it twice in a
|
|
78
|
+
row changes nothing. Run it at least weekly:
|
|
79
|
+
|
|
80
|
+
- NVD older than 7 days: `update` reloads the NVD tables from the yearly feeds by itself.
|
|
81
|
+
- cvelistV5 older than the delta log (about 30 days): `update` refuses; run `vulnmirror fetch`
|
|
82
|
+
and `vulnmirror build`, then `update`.
|
|
83
|
+
- The advisory-database clone missing or re-cloned: `update` clones it again and reloads the
|
|
84
|
+
GHSA tables when the recorded commit is gone.
|
|
85
|
+
|
|
86
|
+
A full rebuild (`vulnmirror fetch && vulnmirror build`) writes to `vulnmirror.sqlite.building`
|
|
87
|
+
and renames it into place when done, so queries keep working meanwhile.
|
|
88
|
+
|
|
89
|
+
## Individual records: `vulnmirror get`
|
|
90
|
+
|
|
91
|
+
Fetch single records by identifier or URL, one at a time or from lists:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
vulnmirror get CVE-2024-3094 # CVE record + NVD record
|
|
95
|
+
vulnmirror get --type nvd CVE-2024-3094 # NVD record only
|
|
96
|
+
vulnmirror get https://nvd.nist.gov/vuln/detail/CVE-2021-44228
|
|
97
|
+
vulnmirror get https://github.com/advisories/GHSA-jfh8-c2jp-5v3q
|
|
98
|
+
vulnmirror get -f refs.txt # one reference per line, '#' comments
|
|
99
|
+
cat refs.txt | vulnmirror get -f - # from stdin
|
|
100
|
+
vulnmirror get -f refs.txt --ingest # also upsert into the database
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| Reference | Record type |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `CVE-YYYY-NNNN` | `cve` and `nvd` (narrow with `--type`) |
|
|
106
|
+
| `GHSA-xxxx-xxxx-xxxx` | `ghsa` |
|
|
107
|
+
| `cve.org`, `cve.mitre.org`, cvelistV5 file URLs | `cve` |
|
|
108
|
+
| `nvd.nist.gov` pages and NVD API URLs | `nvd` |
|
|
109
|
+
| GitHub advisory pages (global or repository), `osv.dev`, `api.osv.dev`, advisory-database file URLs | `ghsa` |
|
|
110
|
+
| any other URL that contains exactly one CVE or GHSA identifier | inferred from the identifier |
|
|
111
|
+
|
|
112
|
+
Records are stored as `raw/cases/<type>/<ID>.json`. An existing file is kept unless you pass
|
|
113
|
+
`--force`, so re-running a long list only fetches what is missing. Every download is logged
|
|
114
|
+
with its source URL in `raw/cases/index.jsonl`.
|
|
115
|
+
|
|
116
|
+
- `cve` comes from cvelistV5 on GitHub.
|
|
117
|
+
- `nvd` comes from the NVD CVE API 2.0. Requests are spaced 6.5 s apart; set `NVD_API_KEY` to
|
|
118
|
+
use the higher limit (0.7 s).
|
|
119
|
+
- `ghsa` is the original file from github/advisory-database, located through the OSV API. If
|
|
120
|
+
the original cannot be fetched, the OSV copy is stored instead and the log says so.
|
|
121
|
+
|
|
122
|
+
## Querying
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
vulnmirror sql "SELECT kind, count(*) FROM reference GROUP BY kind"
|
|
126
|
+
vulnmirror sql "SELECT * FROM kev LIMIT 5" --format json
|
|
127
|
+
vulnmirror filter --vendor examplevendor --from 2024-06-01 --to 2025-05-31 --format csv -o hits.csv
|
|
128
|
+
vulnmirror filter --cpe-part h --cpe-scope any --from 2024-06-01 --to 2025-05-31 --count
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`filter` searches the CNA/ADP `affected` table and the NVD `nvd_cpe` table together; the
|
|
132
|
+
`sources` column says which one matched. Output formats: `tsv` (default), `csv`, `json`, `jsonl`.
|
|
133
|
+
|
|
134
|
+
### Tables
|
|
135
|
+
|
|
136
|
+
| Table | Grain | Notes |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `cve` | one row per CVE ID | from cvelistV5; `state` is `PUBLISHED` or `REJECTED` |
|
|
139
|
+
| `nvd` | one row per CVE ID known to NVD | `status` is NVD's analysis status |
|
|
140
|
+
| `affected` | CVE × provider × product | vendor and product are free text as supplied |
|
|
141
|
+
| `reference` | CVE × provider × URL | `kind` is derived from the URL; `tags` are the provider's own vocabulary |
|
|
142
|
+
| `weakness`, `metric` | CVE × provider × CWE / CVSS version | |
|
|
143
|
+
| `nvd_cpe` | CVE × CPE match | `part` is `a` / `o` / `h`; `vulnerable` = 1 for vulnerable matches |
|
|
144
|
+
| `kev` | one row per KEV entry | |
|
|
145
|
+
| `ghsa` | one row per advisory | `reviewed` = 1 for GitHub-reviewed advisories |
|
|
146
|
+
| `ghsa_alias` | advisory × alias | join to `cve.id` on CVE aliases |
|
|
147
|
+
| `ghsa_affected` | advisory × package | first `introduced` / `fixed` / `last_affected` event; full ranges as JSON |
|
|
148
|
+
| `ghsa_reference`, `ghsa_cwe` | advisory × URL / CWE | |
|
|
149
|
+
| `snapshot` | key / value | source snapshots and sync markers |
|
|
150
|
+
|
|
151
|
+
### Before you count
|
|
152
|
+
|
|
153
|
+
- **NVD marks device hardware as a non-vulnerable platform.** A device CVE is usually modelled as
|
|
154
|
+
vulnerable firmware (`o:*_firmware`, vulnerable = 1) running on hardware (`h`, vulnerable = 0).
|
|
155
|
+
Use `--cpe-scope any` for hardware queries.
|
|
156
|
+
- **NVD CPE coverage is incomplete** for recent CVEs, so CPE-based counts are lower bounds; the
|
|
157
|
+
CNA `affected` table covers records NVD has not analysed yet.
|
|
158
|
+
- **CNA vendor fields are often `n/a`**, with the vendor only in NVD's CPEs. Query both tables
|
|
159
|
+
(`filter` does).
|
|
160
|
+
- **There is no device-type field.** Selecting CVEs for one kind of device means choosing vendor
|
|
161
|
+
and product criteria and checking a sample by hand.
|
|
162
|
+
- **Dates differ by source.** `cve.date_published`, `nvd.published` and `ghsa.published` can be
|
|
163
|
+
hours to days apart; say which one a count uses.
|
|
164
|
+
- **Only GitHub-reviewed advisories** reliably carry package ecosystems and version ranges.
|
|
165
|
+
|
|
166
|
+
## Data terms
|
|
167
|
+
|
|
168
|
+
vulnmirror downloads data; it does not redistribute it. Each source has its own terms:
|
|
169
|
+
|
|
170
|
+
- cvelistV5: "You may search, download, and use the content hosted in this repository, per the
|
|
171
|
+
[CVE Program Terms of Use](https://www.cve.org/Legal/TermsOfUse)" (cvelistV5 README).
|
|
172
|
+
- GitHub Advisory Database: [CC BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md);
|
|
173
|
+
attribute GitHub when you publish derived data.
|
|
174
|
+
- NVD and CISA KEV: see the terms published on their sites.
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
vulnmirror is released under the [BSD Zero Clause License](LICENSE) (SPDX `0BSD`). The data it
|
|
179
|
+
downloads is not covered by this license; see [Data terms](#data-terms).
|
|
180
|
+
|
|
181
|
+
## Development
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
uv sync # environment with dev dependencies
|
|
185
|
+
uv run pytest # unit and integration tests on synthetic data, no network
|
|
186
|
+
VULNMIRROR_REGRESSION=1 uv run pytest -m regression # checks a real mirror against reference counts
|
|
187
|
+
uv run ruff check src tests && uv run ruff format --check src tests
|
|
188
|
+
uv build # sdist and wheel in dist/
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The regression tests compare a real mirror with counts computed independently from the NVD API
|
|
192
|
+
on 2026-09-23 (for example 42,612 non-rejected CVEs published 2024-06-01 to 2025-05-31). NVD
|
|
193
|
+
re-analyses records over time, so these counts carry a tolerance.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vulnmirror"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A local, incrementally updated SQLite mirror of CVE Program, NVD, CISA KEV and GitHub Advisory Database metadata."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "0BSD"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"cve",
|
|
11
|
+
"nvd",
|
|
12
|
+
"kev",
|
|
13
|
+
"ghsa",
|
|
14
|
+
"osv",
|
|
15
|
+
"vulnerability",
|
|
16
|
+
"sqlite",
|
|
17
|
+
"mirror",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"Intended Audience :: Science/Research",
|
|
23
|
+
"Intended Audience :: Information Technology",
|
|
24
|
+
"Operating System :: MacOS",
|
|
25
|
+
"Operating System :: POSIX :: Linux",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Database",
|
|
31
|
+
"Topic :: Security",
|
|
32
|
+
]
|
|
33
|
+
dependencies = ["certifi>=2024.2.2"]
|
|
34
|
+
|
|
35
|
+
[[project.authors]]
|
|
36
|
+
name = "Nemo Xiong"
|
|
37
|
+
email = "nemo@anzupop.com"
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
vulnmirror = "vulnmirror.cli:main"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=8.0",
|
|
45
|
+
"ruff>=0.6",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
50
|
+
build-backend = "uv_build"
|
|
51
|
+
|
|
52
|
+
[tool.uv.build-backend]
|
|
53
|
+
source-include = ["tests/**"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
markers = ["regression: checks a real, fully built mirror against reference counts (opt in with VULNMIRROR_REGRESSION=1)"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 120
|
|
61
|
+
target-version = "py311"
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = [
|
|
65
|
+
"E",
|
|
66
|
+
"F",
|
|
67
|
+
"W",
|
|
68
|
+
"I",
|
|
69
|
+
"B",
|
|
70
|
+
"UP",
|
|
71
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vulnmirror"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A local, incrementally updated SQLite mirror of CVE Program, NVD, CISA KEV and GitHub Advisory Database metadata."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "0BSD"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Nemo Xiong", email = "nemo@anzupop.com" }]
|
|
10
|
+
keywords = ["cve", "nvd", "kev", "ghsa", "osv", "vulnerability", "sqlite", "mirror"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"Intended Audience :: Information Technology",
|
|
16
|
+
"Operating System :: MacOS",
|
|
17
|
+
"Operating System :: POSIX :: Linux",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Database",
|
|
23
|
+
"Topic :: Security",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["certifi>=2024.2.2"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
vulnmirror = "vulnmirror.cli:main"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = ["pytest>=8.0", "ruff>=0.6"]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
35
|
+
build-backend = "uv_build"
|
|
36
|
+
|
|
37
|
+
[tool.uv.build-backend]
|
|
38
|
+
# Ship the test suite in the sdist so downstream packagers can run it.
|
|
39
|
+
source-include = ["tests/**"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
markers = [
|
|
44
|
+
"regression: checks a real, fully built mirror against reference counts (opt in with VULNMIRROR_REGRESSION=1)",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 120
|
|
49
|
+
target-version = "py311"
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "F", "W", "I", "B", "UP"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""vulnmirror: a local, incrementally updated SQLite mirror of CVE, NVD, KEV and GHSA metadata."""
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Full build of <home>/vulnmirror.sqlite from the raw mirrors listed in <home>/MANIFEST.json.
|
|
2
|
+
|
|
3
|
+
The database is written to vulnmirror.sqlite.building and renamed into place only when
|
|
4
|
+
complete, so queries against the previous database keep working during a rebuild.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sqlite3
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from vulnmirror import ghsa, records
|
|
13
|
+
from vulnmirror.config import Paths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def iter_cvelist_export(paths: Paths, asset: str):
|
|
17
|
+
"""Yield CVE JSON 5 records from the daily export (a zip that wraps a zip)."""
|
|
18
|
+
with zipfile.ZipFile(paths.cvelist_dir / asset) as outer:
|
|
19
|
+
inner_names = [n for n in outer.namelist() if n.endswith(".zip")]
|
|
20
|
+
if inner_names:
|
|
21
|
+
inner_path = paths.cvelist_dir / "_inner.zip"
|
|
22
|
+
if not inner_path.exists() or inner_path.stat().st_size != outer.getinfo(inner_names[0]).file_size:
|
|
23
|
+
with outer.open(inner_names[0]) as src, open(inner_path, "wb") as dst:
|
|
24
|
+
while chunk := src.read(1 << 20):
|
|
25
|
+
dst.write(chunk)
|
|
26
|
+
z = zipfile.ZipFile(inner_path)
|
|
27
|
+
else:
|
|
28
|
+
z = outer
|
|
29
|
+
with z:
|
|
30
|
+
for name in z.namelist():
|
|
31
|
+
base = name.rsplit("/", 1)[-1]
|
|
32
|
+
if base.startswith("CVE-") and base.endswith(".json"):
|
|
33
|
+
yield json.loads(z.read(name))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def export_cutoff(asset_name: str) -> str:
|
|
37
|
+
"""'2026-09-23_all_CVEs_at_midnight.zip.zip' -> '2026-09-23T00:00:00Z'."""
|
|
38
|
+
return asset_name[:10] + "T00:00:00Z"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build(paths: Paths, log=print) -> dict:
|
|
42
|
+
if not paths.manifest.exists():
|
|
43
|
+
raise FileNotFoundError(f"no {paths.manifest}; run `vulnmirror fetch` first")
|
|
44
|
+
manifest = json.loads(paths.manifest.read_text())
|
|
45
|
+
tmp = Path(str(paths.db) + ".building")
|
|
46
|
+
tmp.unlink(missing_ok=True)
|
|
47
|
+
db = sqlite3.connect(tmp)
|
|
48
|
+
db.executescript(records.sql_text("schema.sql"))
|
|
49
|
+
db.execute("PRAGMA journal_mode=OFF")
|
|
50
|
+
db.execute("PRAGMA synchronous=OFF")
|
|
51
|
+
|
|
52
|
+
cl = manifest["cvelistV5"]
|
|
53
|
+
log(f"cvelistV5 {cl['release_tag']}")
|
|
54
|
+
n_cl = 0
|
|
55
|
+
for rec in iter_cvelist_export(paths, cl["asset"]):
|
|
56
|
+
if records.insert_cvelist_record(db, rec):
|
|
57
|
+
n_cl += 1
|
|
58
|
+
if n_cl % 100000 == 0:
|
|
59
|
+
log(f" cvelistV5: {n_cl} records")
|
|
60
|
+
db.commit()
|
|
61
|
+
|
|
62
|
+
# 'modified' and 'recent' first: their copy of a CVE is the newest, and the first copy wins.
|
|
63
|
+
feeds = [paths.nvd_dir / f"nvdcve-2.0-{n}.json.gz" for n in ("modified", "recent")]
|
|
64
|
+
feeds = [f for f in feeds if f.exists()] + sorted(paths.nvd_dir.glob("nvdcve-2.0-[0-9]*.json.gz"))
|
|
65
|
+
seen, n_nvd = set(), 0
|
|
66
|
+
for f in feeds:
|
|
67
|
+
for c in records.iter_nvd_feed(f):
|
|
68
|
+
if c["id"] in seen:
|
|
69
|
+
continue
|
|
70
|
+
seen.add(c["id"])
|
|
71
|
+
records.insert_nvd_record(db, c)
|
|
72
|
+
n_nvd += 1
|
|
73
|
+
db.commit()
|
|
74
|
+
log(f" nvd: {f.name} done ({n_nvd} total)")
|
|
75
|
+
|
|
76
|
+
n_kev = records.load_kev(db, paths.kev_file) if paths.kev_file.exists() else 0
|
|
77
|
+
|
|
78
|
+
n_ghsa, commit = 0, ""
|
|
79
|
+
if paths.ghsa_repo.exists():
|
|
80
|
+
n_ghsa, commit = ghsa.load_all(db, paths.ghsa_repo, log=log)
|
|
81
|
+
log(f" ghsa: {n_ghsa} advisories @ {commit[:12]}")
|
|
82
|
+
|
|
83
|
+
nvd_feeds = {f["feed"]: f["lastModifiedDate"] for f in manifest.get("nvd", [])}
|
|
84
|
+
state = dict(
|
|
85
|
+
cvelistV5_release=cl["release_tag"],
|
|
86
|
+
cvelist_synced_through=export_cutoff(cl["asset"]),
|
|
87
|
+
nvd_synced_at=nvd_feeds.get("modified", ""),
|
|
88
|
+
nvd_feeds=json.dumps(nvd_feeds),
|
|
89
|
+
kev_catalog=manifest.get("kev", {}).get("catalogVersion", ""),
|
|
90
|
+
fetched_at=manifest["fetched_at"],
|
|
91
|
+
n_cvelist=n_cl,
|
|
92
|
+
n_nvd=n_nvd,
|
|
93
|
+
n_kev=n_kev,
|
|
94
|
+
n_ghsa=n_ghsa,
|
|
95
|
+
)
|
|
96
|
+
if commit:
|
|
97
|
+
state["ghsa_commit"] = commit
|
|
98
|
+
records.set_state(db, **state)
|
|
99
|
+
db.executescript(records.sql_text("indexes.sql"))
|
|
100
|
+
db.commit()
|
|
101
|
+
db.close()
|
|
102
|
+
tmp.replace(paths.db)
|
|
103
|
+
summary = {"cvelist": n_cl, "nvd": n_nvd, "kev": n_kev, "ghsa": n_ghsa}
|
|
104
|
+
log(f"built {paths.db}: " + " ".join(f"{k}={v}" for k, v in summary.items()))
|
|
105
|
+
return summary
|