Metadata-Version: 2.4
Name: cvepass
Version: 1.0.2
Summary: NVD JSON 2.0 Data Feeds 기반 CVE 로컬 SQLite 데이터베이스 및 버전 범위 정밀 검색 도구
License: MIT
Keywords: cve,nvd,vulnerability,sqlite,cpe,security
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security
Classifier: Environment :: Console
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: excel
Requires-Dist: openpyxl>=3.1; extra == "excel"
Provides-Extra: stream
Requires-Dist: ijson>=3.2; extra == "stream"
Provides-Extra: all
Requires-Dist: openpyxl>=3.1; extra == "all"
Requires-Dist: ijson>=3.2; extra == "all"
Dynamic: license-file

# cvepass

*[한국어 문서](README.ko.md)*

A local CVE database built from the **NVD JSON 2.0 data feeds**, with precise
vendor / product / **version range** matching.

- Data source: <https://nvd.nist.gov/vuln/data-feeds> (JSON 2.0 Feeds)
- Requires Python 3.9+. Works with the standard library alone.

---

## Install

```bash
pip install cvepass
```

```bash
cd cvepass

# Optional but recommended
#   openpyxl : Excel (.xlsx) reports  — without it only CSV is written
#   ijson    : streaming parser for the large year feeds (much less memory)
pip install -r requirements.txt

# Install the `cvepass` command
pip install -e .
```

You can also run it without installing:

```bash
python -m cvepass --help
```

---

## Quick start

```bash
# 1) Build the database (2002 - current year)
cvepass update --all-years

# 2) Refresh later; also writes a before/after comparison report
cvepass update

# 3) Search
cvepass search --vendor apache --product log4j --version 2.14.1
```

Output is in English by default. Add `--lang ko` for Korean, or set
`CVEPASS_LANG=ko`.

---

## Commands

### `update` — refresh the feeds

This is the only command that touches the network.

```bash
cvepass update                       # incremental (modified feed)
cvepass update --all-years           # every year, 2002 to now
cvepass update --year 2025 --year 2026
cvepass update --recent              # only new CVEs from the last 8 days
cvepass update --no-report           # skip the comparison report
cvepass update --force               # re-download and re-import regardless
cvepass update --rebuild --year 2024 # wipe that year and load it fresh
cvepass update --vacuum              # compact the file afterwards
```

Each run:

1. reads every target feed's `.meta` (lastModifiedDate, sha256) and **skips
   feeds that have not changed**,
2. snapshots the current database,
3. downloads, verifies SHA256, and imports — comparing a per-CVE content hash
   so only genuinely changed records are rewritten,
4. compares before and after, and writes a CSV + XLSX report.

With no feed arguments, `update` picks the year feeds when the database is
empty and the `modified` feed otherwise.

| Option | Default |
|---|---|
| `--db` | `%LOCALAPPDATA%\cvepass\cvepass.sqlite` |
| `--report-dir` | `%LOCALAPPDATA%\cvepass\reports` |
| `--cache-dir` | `%LOCALAPPDATA%\cvepass\feeds` |

Environment variables `CVEPASS_DB`, `CVEPASS_REPORT_DIR`, `CVEPASS_CACHE`
and `CVEPASS_HOME` override these.

### `search`

```bash
# Version range matching
cvepass search --vendor apache --product log4j --version 2.14.1
cvepass search --vendor apache --product log4j --version 2.17.1   # no match (patched)

# Combine filters
cvepass search --product openssl --version 1.0.1 --min-score 7.0
cvepass search --keyword "buffer overflow" --severity CRITICAL --limit 20
cvepass search --cve "CVE-2024-*" --cwe CWE-79 --sort score

# Choose the match source (default: both)
cvepass search --product log4j --source cpe   # NVD analysis only
cvepass search --product log4j --source cna   # as submitted by the CNA

# Export
cvepass search --vendor google --product android --version 13 -o result.xlsx
cvepass search --vendor microsoft --all --format json > ms.json
```

| Option | Meaning |
|---|---|
| `--vendor`, `--product` | Substring match; `*` and `?` wildcards. `--exact` for exact match |
| `--version` | Only return CVEs whose affected range covers this version |
| `--exclude-all-versions` | Drop entries that cover every version (no bounds) |
| `--severity` | `NONE/LOW/MEDIUM/HIGH/CRITICAL`, repeatable |
| `--min-score`, `--max-score` | CVSS score range |
| `--published-after`, `--published-before`, `--modified-after` | `YYYY-MM-DD` |
| `--sort` | `cve` / `published` / `modified` / `score` |
| `--format` | `table` (default) / `json` / `jsonl` / `csv` |
| `-o, --output` | Write to `.csv` / `.xlsx` / `.json` |
| `--count` | Print the number of matches only |

Exit code is `1` when nothing matches, which makes it easy to use in scripts.

> `--format csv` (stdout) keeps machine-readable English column names in every
> language. File exports via `-o` use the active language.

### `show`

```bash
cvepass show CVE-2021-44228          # summary, version ranges, references
cvepass show CVE-2021-44228 --all    # every range and reference
cvepass show CVE-2021-44228 --json   # the stored record as JSON
```

### `stats`, `vendors`, `products`

```bash
cvepass stats --by-year
cvepass vendors --prefix apache
cvepass products --vendor apache --limit 30
```

---

## How version matching works

Two independent sources are indexed, and both are used by default.

### 1. CPE ranges (NVD analysis)

- If the CPE's version component is concrete, only that version matches
  (`3.1.*` style wildcards are handled).
- If it is `*`, the bounds decide: `versionStartIncluding` / `StartExcluding`
  and `versionEndIncluding` / `EndExcluding`.
- No bounds at all means **every version** is affected. Use
  `--exclude-all-versions` to drop those.
- Entries marked `vulnerable=false` (platform conditions) are never matched.

### 2. CNA records (`affected`, CVE Record 5.x)

Evaluated per `affectedData` block. For a target version:

1. if it falls in an `unaffected` range → not vulnerable (already fixed),
2. otherwise if it falls in an `affected` range → vulnerable,
3. otherwise → follow the block's `defaultStatus`.

Step 3 matters. Linux kernel records, for example, express most of their
affected range through `defaultStatus` alone:

```
defaultStatus: affected
  5.2                       affected      ← introduced here
  0 ≤ v < 5.2               unaffected
  5.4.182 ≤ v ≤ 5.4.*       unaffected    ← fixed, per stable branch
  5.15.26 ≤ v ≤ 5.15.*      unaffected
  5.17 ≤ v ≤ *              unaffected
```

`5.12.4` appears in none of those ranges, but it is an end-of-life branch that
never received the fix, so `defaultStatus` correctly marks it vulnerable.

Wildcards in the bounds are resolved:

| Written as | Means |
|---|---|
| `lessThanOrEqual='*'` | no upper bound (`version` and above) |
| `lessThan='*'` | no upper bound — Siemens writes `version='V3.1.5', lessThan='*'` for "V3.1.5 and later" |
| `lessThanOrEqual='6.18.*'` | `v < 6.19` (the whole 6.18 series) |
| `lessThan='6.18.*'` | `v < 6.18` |

**Commit hashes are never range-compared.** The Linux kernel CNA marks the
introducing and fixing commits with `versionType='git'`. A hash carries no
ordering — neither lexical nor numeric order reflects git ancestry — so those
rows are excluded from version matching. Rows without an explicit
`versionType` are detected by shape (11+ hex characters, all-digit values
excluded).

---

## Version comparison

Versions are compared token by token, loosely:

```
1.10   > 1.9          numbers compare as integers
1.01  == 1.1          leading zeros ignored
1.2.3 == 1_2_3        separators (. _ - +) ignored
1.0    > 1.0-rc1      pre-release (dev/alpha/beta/pre/rc/snapshot) sorts lower
1.0-sp1 > 1.0         post-release (patch/sp/update/fix ...) sorts higher
1.0.1  > 1.0.beta     numbers beat letters
v1.2.5 > 1.2.4        a meaningless leading 'v' is stripped
```

### Per-product schemes

A trailing letter means different things to different vendors, and no single
global rule is consistent:

| Product | NVD range | Query | Should match? | Requires |
|---|---|---|---|---|
| openssl | `1.0.1 ≤ v < 1.0.1g` | `1.0.1` | yes (Heartbleed) | letter **>** no letter |
| cisco ios | `v ≤ 12.1` | `12.1xc` | yes (release train) | letter **≤** no letter |

So cvepass decides **per product, from the shape of the range bounds**:

- if NVD puts a trailing letter in a **bound** (`versionEndExcluding=1.0.1g`),
  that product treats letters as ordered patch levels,
- if trailing letters only ever appear in **individual CPE versions**
  (`12.1xc`), the default comparator is kept.

Two more schemes are recognised by shape alone:

| Scheme | Applies to | Example |
|---|---|---|
| `arm-driver` | Arm GPU kernel drivers, `rNNpN` | `r44p0 < r49p4`, `r9p0 < r10p0` |
| `date` | date-style versions | `2000-09-01 < 2001-05-31` |

Adding your own takes one line:

```python
import re
from cvepass.version_schemes import VersionScheme, letter_patch_for, register

register(letter_patch_for(vendor="myvendor"))       # trailing letter = patch

register(VersionScheme(                              # a brand-new format
    name="build-number",
    pattern=re.compile(r"^\d+\.\d+\.b\d+$", re.I),   # 3.2.b1041
    key=lambda v: tuple(int(x) for x in re.findall(r"\d+", v)),
    vendor=re.compile(r"^myvendor$"),
))
```

---

## Comparison reports

Unless you pass `--no-report`, every `update` writes two files:

```
cvepass_update_20260810_133000.csv
cvepass_update_20260810_133000.xlsx
```

The CSV holds one row per changed CVE (UTF-8 with BOM, so Excel opens it
cleanly). The workbook has five sheets:

| Sheet | Contents |
|---|---|
| Summary | Timestamp, feeds updated, counts before and after |
| Added | Newly published CVEs |
| Modified | Changed CVEs, with the changed fields and details |
| Removed | CVEs that disappeared |
| Field changes | One row per CVE × field, `before → after` |

Compared fields: published date, last modified, status, severity, score, CVSS
version, CWE, description, reference count, CPE range count, affected entry
count. Anything else is still detected through the content hash and reported
as "Other fields changed".

---

## Database layout

Five data tables, plus `db_meta` and a temporary `update_snapshot`.

```
feed(name, last_modified, sha256, size, checked_at, imported_at, cve_count)

cve(id PK, published, last_modified, status,
    severity, base_score, cvss_version, vector,
    cwes, description, refs, hash)

cpe(id PK, part, vendor, product, version, update_str, edition,
    language, sw_edition, target_sw, target_hw, other)    -- UNIQUE, deduplicated

cve_cpe(cve_id, cpe_id, vulnerable,
        v_start_inc, v_start_exc, v_end_inc, v_end_exc)

cve_affected(cve_id, block_id, vendor, product, version,
             less_than, less_eq, version_type,
             status, default_status)
```

`cpe` is deduplicated across the corpus, which keeps a full build well under a
gigabyte. `cve_affected` groups rows of the same `affectedData` item under
`block_id`, because `defaultStatus` has to be evaluated per block.

The schema version is stored in the database. If it does not match the
program, delete the file and rebuild with `cvepass update --all-years`.

---

## Use as a library

```python
from cvepass.db import CveDB
from cvepass.i18n import set_language
from cvepass.search import SearchQuery, Searcher

set_language("en")          # or "ko"

with CveDB("D:/data/cvepass.sqlite", read_only=True) as db:
    searcher = Searcher(db)
    for result in searcher.search(
        SearchQuery(vendor="apache", product="log4j", version="2.14.1", limit=None)
    ):
        print(result.cve_id, result.severity, result.base_score)
        for match in result.matches:
            print("   ", match.as_text())

    record = searcher.get_cve("CVE-2021-44228")
    ranges = searcher.get_affected_ranges("CVE-2021-44228")
```

---

## Tests

```bash
python -m unittest discover -s tests -v
```

Everything runs offline against synthetic feeds — import, search, comparison
reports, version schemes and translations.

---

## Notes

- Year feeds refresh once a day; `modified` and `recent` roughly every two hours.
- The `2002` feed also contains CVEs from 1999-2001.
- Without `ijson`, a year feed is loaded into memory in one piece.
- Repeated re-imports can leave unreferenced `cpe` rows. `--rebuild` cleans
  them up, and `--vacuum` reclaims the space.
