oebc 0.1.0__py3-none-any.whl

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.
oebc/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("oebc")
5
+ except PackageNotFoundError: # pragma: no cover
6
+ __version__ = "0.0.0"
oebc/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__": # pragma: no cover
4
+ main()
oebc/cli.py ADDED
@@ -0,0 +1,48 @@
1
+ """CLI dispatcher for the oebc command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from . import __version__
8
+ from .fetch import main as fetch_main
9
+ from .generate import _generate_parser
10
+ from .generate import main as generate_main
11
+
12
+
13
+ def main() -> None:
14
+ parser = argparse.ArgumentParser(
15
+ prog="oebc",
16
+ description="Open Exposure Baseline Catalog (OEBC) CLI",
17
+ )
18
+ parser.add_argument(
19
+ "--version",
20
+ action="version",
21
+ version=f"%(prog)s {__version__}",
22
+ )
23
+
24
+ subparsers = parser.add_subparsers(dest="command", required=True)
25
+
26
+ # fetch subcommand
27
+ fetch_p = subparsers.add_parser(
28
+ "fetch",
29
+ help="Download the pre-built catalog from openastra.org (fast)",
30
+ )
31
+ fetch_p.add_argument(
32
+ "--out",
33
+ default="oebc_full_catalog_enriched.json",
34
+ help="Output file path (default: oebc_full_catalog_enriched.json)",
35
+ )
36
+
37
+ # generate subcommand — inherits all flags from _generate_parser
38
+ subparsers.add_parser(
39
+ "generate",
40
+ help="Generate the catalog from scratch from NVD, EPSS, and CISA KEV sources",
41
+ parents=[_generate_parser(add_help=False)],
42
+ )
43
+
44
+ args = parser.parse_args()
45
+ if args.command == "fetch":
46
+ fetch_main(args)
47
+ else:
48
+ generate_main(args)
oebc/fetch.py ADDED
@@ -0,0 +1,24 @@
1
+ """Download the pre-built OEBC catalog from openastra.org."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import gzip
7
+ import json
8
+
9
+ import requests
10
+
11
+ from .urls import OEBC_CATALOG_URL
12
+
13
+
14
+ def main(args: argparse.Namespace) -> None:
15
+ print(f"Fetching catalog from {OEBC_CATALOG_URL} ...")
16
+ r = requests.get(OEBC_CATALOG_URL, timeout=60)
17
+ r.raise_for_status()
18
+ # Catalog is gzip-compressed. Decompress regardless of Content-Encoding header
19
+ # (GitHub Pages does not automatically set Content-Encoding for .gz files).
20
+ raw = gzip.decompress(r.content)
21
+ data = json.loads(raw)
22
+ with open(args.out, "w", encoding="utf-8") as f:
23
+ json.dump(data, f, ensure_ascii=False, indent=2)
24
+ print(f"Wrote {data.get('count', '?')} vulnerabilities to {args.out}")
oebc/generate.py ADDED
@@ -0,0 +1,305 @@
1
+ """Core catalog generation logic for OEBC.
2
+
3
+ Fetches CVE data from NVD, EPSS scores from FIRST.org, and KEV list from CISA.
4
+ Joins all three by CVE ID, computes exposure_tier and actionable, writes JSON.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ from datetime import UTC, datetime
12
+ from typing import Any
13
+
14
+ import requests
15
+ from requests.adapters import HTTPAdapter
16
+ from urllib3.util.retry import Retry
17
+
18
+ from .urls import EPSS_URL, KEV_URL, NVD_URL
19
+
20
+ # NVD API returns at most 2000 results per page
21
+ _NVD_PAGE_SIZE = 2000
22
+
23
+ # EPSS API page size — fetch in large batches
24
+ _EPSS_PAGE_SIZE = 10000
25
+
26
+ # Tier ordering for actionable comparison
27
+ _TIER_ORDER = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
28
+
29
+
30
+ # ─── Data fetching ───────────────────────────────────────────────────────────
31
+
32
+ def _make_session() -> requests.Session:
33
+ """Return a requests Session with automatic retry on transient errors."""
34
+ session = requests.Session()
35
+ retry = Retry(
36
+ total=5,
37
+ backoff_factor=2,
38
+ status_forcelist=[429, 500, 502, 503, 504],
39
+ allowed_methods=["GET"],
40
+ raise_on_status=False,
41
+ )
42
+ adapter = HTTPAdapter(max_retries=retry)
43
+ session.mount("https://", adapter)
44
+ session.mount("http://", adapter)
45
+ return session
46
+
47
+
48
+ def fetch_nvd_cves(nvd_url: str, api_key: str | None = None) -> list[dict]:
49
+ """Fetch all CVEs from the NVD API (paginated). Returns list of raw CVE dicts."""
50
+ cves: list[dict] = []
51
+ start = 0
52
+ total = None
53
+
54
+ while total is None or start < total:
55
+ params: dict = {"startIndex": start, "resultsPerPage": _NVD_PAGE_SIZE}
56
+ if api_key:
57
+ params["apiKey"] = api_key
58
+ r = _make_session().get(
59
+ nvd_url,
60
+ params=params,
61
+ timeout=60,
62
+ )
63
+ r.raise_for_status()
64
+ data = r.json()
65
+ total = data["totalResults"]
66
+ page_cves = [v["cve"] for v in data.get("vulnerabilities", [])]
67
+ cves.extend(page_cves)
68
+ start += len(page_cves)
69
+ if len(page_cves) == 0:
70
+ break
71
+
72
+ return cves
73
+
74
+
75
+ def fetch_epss_scores(epss_url: str) -> dict[str, dict]:
76
+ """Fetch all EPSS scores (paginated). Returns dict keyed by CVE ID."""
77
+ scores: dict[str, dict] = {}
78
+ offset = 0
79
+ total = None
80
+
81
+ while total is None or offset < total:
82
+ r = _make_session().get(
83
+ epss_url,
84
+ params={"offset": offset, "limit": _EPSS_PAGE_SIZE},
85
+ timeout=60,
86
+ )
87
+ r.raise_for_status()
88
+ data = r.json()
89
+ total = data["total"]
90
+ page_data = data.get("data", [])
91
+ for record in page_data:
92
+ scores[record["cve"]] = {
93
+ "epss": float(record["epss"]),
94
+ "percentile": float(record["percentile"]),
95
+ }
96
+ offset += len(page_data)
97
+ if len(page_data) == 0:
98
+ break
99
+
100
+ return scores
101
+
102
+
103
+ def fetch_kev(kev_url: str) -> dict[str, dict]:
104
+ """Fetch CISA KEV list. Returns dict keyed by CVE ID."""
105
+ r = _make_session().get(kev_url, timeout=30)
106
+ r.raise_for_status()
107
+ data = r.json()
108
+ return {
109
+ v["cveID"]: {
110
+ "date_added": v.get("dateAdded"),
111
+ "due_date": v.get("dueDate"),
112
+ "ransomware_use": v.get("knownRansomwareCampaignUse"),
113
+ }
114
+ for v in data.get("vulnerabilities", [])
115
+ }
116
+
117
+
118
+ # ─── CVE field extraction ────────────────────────────────────────────────────
119
+
120
+ def extract_cvss(cve: dict) -> tuple[float | None, str | None, str | None, str | None]:
121
+ """Extract (score, vector, version, severity) from a NVD CVE dict.
122
+
123
+ Prefers CVSS v3.1, then v3.0. Returns (None, None, None, None) if no score assigned.
124
+ """
125
+ metrics = cve.get("metrics", {})
126
+ for key in ("cvssMetricV31", "cvssMetricV30"):
127
+ entries = metrics.get(key, [])
128
+ if entries:
129
+ d = entries[0]["cvssData"]
130
+ return (
131
+ d.get("baseScore"),
132
+ d.get("vectorString"),
133
+ d.get("version"),
134
+ d.get("baseSeverity"),
135
+ )
136
+ return None, None, None, None
137
+
138
+
139
+ def extract_cwes(cve: dict) -> list[str]:
140
+ """Extract CWE IDs from a NVD CVE dict, excluding NVD placeholder values."""
141
+ _PLACEHOLDERS = {"NVD-CWE-noinfo", "NVD-CWE-Other"}
142
+ result = []
143
+ for weakness in cve.get("weaknesses", []):
144
+ for desc in weakness.get("description", []):
145
+ val = desc.get("value", "")
146
+ if desc.get("lang") == "en" and val not in _PLACEHOLDERS:
147
+ result.append(val)
148
+ return result
149
+
150
+
151
+ def extract_description(cve: dict) -> str:
152
+ """Return the English description for a NVD CVE, or empty string if none."""
153
+ for desc in cve.get("descriptions", []):
154
+ if desc.get("lang") == "en":
155
+ return desc.get("value", "")
156
+ return ""
157
+
158
+
159
+ # ─── Enrichment ──────────────────────────────────────────────────────────────
160
+
161
+ def compute_exposure_tier(
162
+ kev_listed: bool,
163
+ epss_score: float | None,
164
+ cvss_score: float | None,
165
+ epss_high_threshold: float,
166
+ epss_medium_threshold: float,
167
+ ) -> str:
168
+ """Compute exposure tier. Rules evaluated in priority order; first match wins.
169
+
170
+ Tier ordering: LOW < MEDIUM < HIGH < CRITICAL
171
+ Null scores do not qualify any threshold comparison.
172
+ """
173
+ if kev_listed:
174
+ return "CRITICAL"
175
+ epss_ok = epss_score is not None
176
+ cvss_ok = cvss_score is not None
177
+ if (epss_ok and epss_score >= epss_high_threshold) or (cvss_ok and cvss_score >= 9.0):
178
+ return "HIGH"
179
+ if (epss_ok and epss_score >= epss_medium_threshold) or (cvss_ok and cvss_score >= 7.0):
180
+ return "MEDIUM"
181
+ return "LOW"
182
+
183
+
184
+ def compute_actionable(exposure_tier: str, actionable_min_tier: str) -> bool:
185
+ """Return True if exposure_tier >= actionable_min_tier (LOW < MEDIUM < HIGH < CRITICAL)."""
186
+ return _TIER_ORDER[exposure_tier] >= _TIER_ORDER[actionable_min_tier.upper()]
187
+
188
+
189
+ # ─── Catalog assembly ────────────────────────────────────────────────────────
190
+
191
+ def build_catalog(
192
+ nvd_cves: list[dict],
193
+ epss_scores: dict[str, dict],
194
+ kev_data: dict[str, dict],
195
+ args: argparse.Namespace,
196
+ ) -> dict[str, Any]:
197
+ """Join NVD + EPSS + KEV, compute derived fields, return catalog dict."""
198
+ vulnerabilities = []
199
+ for cve in nvd_cves:
200
+ cve_id = cve["id"]
201
+ cvss_score, cvss_vector, cvss_version, cvss_severity = extract_cvss(cve)
202
+ epss = epss_scores.get(cve_id)
203
+ epss_score = epss["epss"] if epss else None
204
+ epss_percentile = epss["percentile"] if epss else None
205
+ kev = kev_data.get(cve_id)
206
+ kev_listed = kev is not None
207
+ exposure_tier = compute_exposure_tier(
208
+ kev_listed, epss_score, cvss_score,
209
+ args.epss_high_threshold, args.epss_medium_threshold,
210
+ )
211
+ vulnerabilities.append({
212
+ "cve_id": cve_id,
213
+ "description": extract_description(cve),
214
+ "published_date": cve.get("published", "")[:10],
215
+ "last_modified_date": cve.get("lastModified", "")[:10],
216
+ "cwe_ids": extract_cwes(cve),
217
+ "cvss_score": cvss_score,
218
+ "cvss_vector": cvss_vector,
219
+ "cvss_version": cvss_version,
220
+ "cvss_severity": cvss_severity,
221
+ "epss_score": epss_score,
222
+ "epss_percentile": epss_percentile,
223
+ "kev_listed": kev_listed,
224
+ "kev_date_added": kev["date_added"] if kev else None,
225
+ "kev_due_date": kev["due_date"] if kev else None,
226
+ "kev_ransomware_use": kev["ransomware_use"] if kev else None,
227
+ "exposure_tier": exposure_tier,
228
+ "actionable": compute_actionable(exposure_tier, args.actionable_min_tier),
229
+ })
230
+
231
+ return {
232
+ "project": "Open Exposure Baseline Catalog (OEBC)",
233
+ "project_version": _package_version(),
234
+ "generated_at_utc": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
235
+ "sources": {
236
+ "nvd": args.nvd_url,
237
+ "epss": args.epss_url,
238
+ "kev": args.kev_url,
239
+ },
240
+ "rules": {
241
+ "exposure_tier_definition": {
242
+ "critical": "kev_listed = true",
243
+ "high": f"epss_score >= {args.epss_high_threshold} OR cvss_score >= 9.0",
244
+ "medium": f"epss_score >= {args.epss_medium_threshold} OR cvss_score >= 7.0",
245
+ "low": "everything else",
246
+ },
247
+ "tier_order": "LOW < MEDIUM < HIGH < CRITICAL",
248
+ "actionable_min_tier": args.actionable_min_tier,
249
+ },
250
+ "count": len(vulnerabilities),
251
+ "vulnerabilities": vulnerabilities,
252
+ }
253
+
254
+
255
+ def _package_version() -> str:
256
+ from . import __version__
257
+ return __version__
258
+
259
+
260
+ # ─── CLI entry point ─────────────────────────────────────────────────────────
261
+
262
+ def _generate_parser(add_help: bool = True) -> argparse.ArgumentParser:
263
+ """Return the ArgumentParser for the generate subcommand."""
264
+ p = argparse.ArgumentParser(
265
+ prog="oebc generate",
266
+ description="Generate the OEBC catalog from NVD, EPSS, and CISA KEV sources.",
267
+ add_help=add_help,
268
+ )
269
+ p.add_argument("--out", default="oebc_full_catalog_enriched.json",
270
+ help="Output file path (default: oebc_full_catalog_enriched.json)")
271
+ p.add_argument("--actionable_min_tier", default="medium",
272
+ choices=["low", "medium", "high", "critical"],
273
+ help="Minimum tier for actionable=true (default: medium)")
274
+ p.add_argument("--epss_high_threshold", type=float, default=0.10,
275
+ help="EPSS score threshold for HIGH tier (default: 0.10)")
276
+ p.add_argument("--epss_medium_threshold", type=float, default=0.01,
277
+ help="EPSS score threshold for MEDIUM tier (default: 0.01)")
278
+ p.add_argument("--nvd_url", default=NVD_URL, help="Override NVD source URL")
279
+ p.add_argument("--epss_url", default=EPSS_URL, help="Override EPSS source URL")
280
+ p.add_argument("--kev_url", default=KEV_URL, help="Override CISA KEV source URL")
281
+ p.add_argument("--nvd_api_key", default=None,
282
+ help="NVD API key for higher rate limits (optional)")
283
+ return p
284
+
285
+
286
+ def main(args: argparse.Namespace) -> None:
287
+ """Generate the catalog from live sources and write to args.out."""
288
+ print(f"Fetching CVEs from NVD: {args.nvd_url}")
289
+ nvd_cves = fetch_nvd_cves(args.nvd_url, api_key=getattr(args, "nvd_api_key", None))
290
+ print(f" → {len(nvd_cves)} CVEs fetched")
291
+
292
+ print(f"Fetching EPSS scores: {args.epss_url}")
293
+ epss_scores = fetch_epss_scores(args.epss_url)
294
+ print(f" → {len(epss_scores)} EPSS records fetched")
295
+
296
+ print(f"Fetching CISA KEV: {args.kev_url}")
297
+ kev_data = fetch_kev(args.kev_url)
298
+ print(f" → {len(kev_data)} KEV entries fetched")
299
+
300
+ print("Building catalog...")
301
+ catalog = build_catalog(nvd_cves, epss_scores, kev_data, args)
302
+
303
+ with open(args.out, "w", encoding="utf-8") as f:
304
+ json.dump(catalog, f, ensure_ascii=False, indent=2)
305
+ print(f"Wrote {catalog['count']} vulnerabilities to {args.out}")
oebc/urls.py ADDED
@@ -0,0 +1,17 @@
1
+ """Default source URLs for OEBC data sources and published catalog.
2
+
3
+ Override any URL via CLI flags or by setting OEBC_*_URL environment variables.
4
+ """
5
+
6
+ # NVD CVE 2.0 API — paginated, max 2000 results per page
7
+ NVD_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"
8
+
9
+ # FIRST.org EPSS API — exploitation probability scores for all CVEs
10
+ EPSS_URL = "https://api.first.org/data/v1/epss"
11
+
12
+ # CISA Known Exploited Vulnerabilities catalog
13
+ KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
14
+
15
+ # Pre-built OEBC catalog published daily to openastra.org (gzip-compressed JSON)
16
+ # Update this constant when the schema major/minor version changes (e.g., v0.1 → v0.2)
17
+ OEBC_CATALOG_URL = "https://openastra.org/oebc/catalog/v0.1/latest.json.gz"
@@ -0,0 +1,451 @@
1
+ Metadata-Version: 2.4
2
+ Name: oebc
3
+ Version: 0.1.0
4
+ Summary: Open Exposure Baseline Catalog — daily CVE exposure baseline enriched with EPSS, KEV, and derived exposure tiers.
5
+ Author: Karthikeyan Sadayamuthu
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://openastra.org
8
+ Project-URL: Source, https://github.com/sadayamuthu/oebc
9
+ Project-URL: Issues, https://github.com/sadayamuthu/oebc/issues
10
+ Project-URL: Catalog, https://openastra.org/oebc/catalog/v0.1/latest.json.gz
11
+ Keywords: cve,epss,kev,vulnerability,exposure,security,nvd
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Information Technology
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Security
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: requests>=2.31
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0; extra == "dev"
29
+ Requires-Dist: pytest-cov>=6.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.4; extra == "dev"
31
+ Requires-Dist: pre-commit>=4.0; extra == "dev"
32
+ Requires-Dist: jsonschema>=4.0; extra == "dev"
33
+ Requires-Dist: build>=1.0; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ <div align="center">
37
+
38
+ # Open Exposure Baseline Catalog (OEBC)
39
+
40
+ **Machine-Readable CVE Exposure Baseline enriched with EPSS, KEV, and Derived Exposure Tiers**
41
+
42
+ [![CI](https://github.com/sadayamuthu/oebc/actions/workflows/develop.yml/badge.svg)](https://github.com/sadayamuthu/oebc/actions/workflows/develop.yml)
43
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/)
44
+ [![License](https://img.shields.io/github/license/sadayamuthu/oebc)](LICENSE)
45
+ [![Catalog](https://img.shields.io/badge/catalog-latest-green)](https://openastra.org/oebc/catalog/v0.1/latest.json.gz)
46
+ [![Schema](https://img.shields.io/badge/schema-v0.1-blue)](https://openastra.org/oebc/schema/v0.1/oebc.json)
47
+
48
+ [Catalog](#catalog) · [Quick Start](#quick-start) · [CLI](#cli-usage) · [Schema](#output-schema) · [Automation](#automation)
49
+
50
+ </div>
51
+
52
+ ---
53
+
54
+ ## What is OEBC?
55
+
56
+ OEBC is a daily, machine-readable, cloud-agnostic **exposure baseline catalog** derived from three authoritative sources:
57
+
58
+ - **[NVD](https://nvd.nist.gov/)** — CVE descriptions, CVSS scores, CWE IDs, published/modified dates
59
+ - **[FIRST.org EPSS](https://www.first.org/epss/)** — Exploitation probability score and percentile per CVE
60
+ - **[CISA KEV](https://www.cisa.gov/known-exploited-vulnerabilities-catalog)** — Known Exploited Vulnerabilities with ransomware use flags
61
+
62
+ Every CVE is enriched with derived fields (`exposure_tier`, `actionable`) and published as a gzip-compressed JSON catalog at:
63
+
64
+ **`https://openastra.org/oebc/catalog/v0.1/latest.json.gz`**
65
+
66
+ OEBC is consumed by **ExposureGate**, a CTEM (Continuous Threat Exposure Management) platform built on exposure baselines.
67
+
68
+ ---
69
+
70
+ ## How It Works
71
+
72
+ ```
73
+ NVD CVE API FIRST.org EPSS CISA KEV
74
+ (250k+ CVEs) (EPSS scores) (Known exploited)
75
+ │ │ │
76
+ └────────────────────┴────────────────────┘
77
+ │
78
+ oebc generate
79
+ │
80
+ Join by CVE ID
81
+ │
82
+ Enrich: exposure_tier + actionable
83
+ │
84
+ ┌────────────────┴──────────────────┐
85
+ │ │
86
+ latest.json.gz GitHub Release asset
87
+ (openastra.org repo) oebc-catalog-YYYY-MM-DD.json.gz
88
+ │
89
+ openastra.org/oebc/catalog/v0.1/
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Catalog
95
+
96
+ | Artifact | URL |
97
+ |----------|-----|
98
+ | Latest catalog (gzip) | `https://openastra.org/oebc/catalog/v0.1/latest.json.gz` |
99
+ | Historical snapshots | GitHub Release assets on each `baseline-YYYY-MM-DD` release |
100
+ | JSON Schema | `https://openastra.org/oebc/schema/v0.1/oebc.json` |
101
+ | YAML Schema | `https://openastra.org/oebc/schema/v0.1/oebc.yaml` |
102
+
103
+ > The catalog is gzip-compressed to stay under GitHub's 100 MB file size limit (raw JSON is ~290 MB; compressed is ~25–30 MB). Historical daily snapshots are attached as release assets rather than committed to the repo, keeping repo size stable.
104
+
105
+ ---
106
+
107
+ ## Quick Start
108
+
109
+ **Download the pre-built catalog (fastest):**
110
+
111
+ ```bash
112
+ # Download and decompress in one step
113
+ curl -L https://openastra.org/oebc/catalog/v0.1/latest.json.gz | gunzip > oebc_catalog.json
114
+ ```
115
+
116
+ **Or use the CLI:**
117
+
118
+ ```bash
119
+ pip install oebc
120
+
121
+ # Download pre-built catalog from openastra.org
122
+ oebc fetch
123
+
124
+ # Generate catalog from scratch (fetches all live sources)
125
+ oebc generate
126
+ ```
127
+
128
+ **Or run from source:**
129
+
130
+ ```bash
131
+ git clone https://github.com/sadayamuthu/oebc.git
132
+ cd oebc
133
+ make install-dev
134
+ make generate
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Features
140
+
141
+ - **250k+ CVEs** — full NVD catalog, updated daily
142
+ - **Three authoritative sources** — NVD, EPSS, CISA KEV joined by CVE ID
143
+ - **Derived exposure tiers** — CRITICAL / HIGH / MEDIUM / LOW from KEV, EPSS and CVSS
144
+ - **Actionable flag** — boolean shortcut for tiers at or above a configurable minimum
145
+ - **Null-safe enrichment** — missing CVSS or EPSS scores handled gracefully
146
+ - **Configurable thresholds** — EPSS cutoffs exposed as CLI flags (CVSS bands are fixed at 9.0 and 7.0)
147
+ - **Retry-resilient** — exponential backoff on NVD / EPSS / KEV HTTP failures
148
+ - **Versioned schema** — JSON Schema Draft 2020-12 with `additionalProperties: false`, checked by the test suite
149
+ - **PyPI package** — `pip install oebc`
150
+
151
+ ---
152
+
153
+ ## CLI Usage
154
+
155
+ ### `oebc fetch`
156
+
157
+ Download the pre-built catalog from openastra.org (fast, no live API calls).
158
+
159
+ ```bash
160
+ oebc fetch
161
+ oebc fetch --out my-catalog.json
162
+ ```
163
+
164
+ | Flag | Default | Description |
165
+ |------|---------|-------------|
166
+ | `--out` | `oebc_full_catalog_enriched.json` | Output file path |
167
+
168
+ ### `oebc generate`
169
+
170
+ Fetch NVD + EPSS + CISA KEV live and generate the full enriched catalog.
171
+
172
+ ```bash
173
+ oebc generate
174
+ oebc generate --out catalog.json \
175
+ --actionable_min_tier high \
176
+ --epss_high_threshold 0.10 \
177
+ --epss_medium_threshold 0.01 \
178
+ --nvd_api_key YOUR_KEY
179
+ ```
180
+
181
+ | Flag | Default | Description |
182
+ |------|---------|-------------|
183
+ | `--out` | `oebc_full_catalog_enriched.json` | Output file path |
184
+ | `--actionable_min_tier` | `medium` | Minimum tier for `actionable=true` (`low`, `medium`, `high`, `critical`) |
185
+ | `--epss_high_threshold` | `0.10` | EPSS score threshold for HIGH tier |
186
+ | `--epss_medium_threshold` | `0.01` | EPSS score threshold for MEDIUM tier |
187
+ | `--nvd_url` | NVD API URL | Override NVD source URL |
188
+ | `--epss_url` | FIRST.org EPSS URL | Override EPSS source URL |
189
+ | `--kev_url` | CISA KEV JSON URL | Override KEV source URL |
190
+ | `--nvd_api_key` | *(none)* | NVD API key for higher rate limits (optional) |
191
+
192
+ ---
193
+
194
+ ## Output Schema
195
+
196
+ ### Top-Level Structure
197
+
198
+ ```json
199
+ {
200
+ "project": "Open Exposure Baseline Catalog (OEBC)",
201
+ "project_version": "0.1.0",
202
+ "generated_at_utc": "2026-03-16T06:00:00Z",
203
+ "sources": {
204
+ "nvd": "https://services.nvd.nist.gov/rest/json/cves/2.0",
205
+ "epss": "https://api.first.org/data/v1/epss",
206
+ "kev": "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
207
+ },
208
+ "rules": { ... },
209
+ "count": 250000,
210
+ "vulnerabilities": [ ... ]
211
+ }
212
+ ```
213
+
214
+ ### Per-CVE Record
215
+
216
+ ```json
217
+ {
218
+ "cve_id": "CVE-2021-44228",
219
+ "description": "Apache Log4j2 JNDI...",
220
+ "published_date": "2021-12-10",
221
+ "last_modified_date": "2023-03-17",
222
+ "cwe_ids": ["CWE-917", "CWE-20"],
223
+ "cvss_score": 10.0,
224
+ "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
225
+ "cvss_version": "3.1",
226
+ "cvss_severity": "CRITICAL",
227
+ "epss_score": 0.97566,
228
+ "epss_percentile": 0.99983,
229
+ "kev_listed": true,
230
+ "kev_date_added": "2021-12-10",
231
+ "kev_due_date": "2021-12-24",
232
+ "kev_ransomware_use": "Known",
233
+ "exposure_tier": "CRITICAL",
234
+ "actionable": true
235
+ }
236
+ ```
237
+
238
+ ### Field Reference
239
+
240
+ | Field | Type | Description |
241
+ |-------|------|-------------|
242
+ | `cve_id` | string | CVE identifier (e.g., `CVE-2021-44228`) |
243
+ | `description` | string | English description from NVD |
244
+ | `published_date` | string | ISO 8601 date first published |
245
+ | `last_modified_date` | string | ISO 8601 date last modified |
246
+ | `cwe_ids` | array | CWE identifiers (empty array if none) |
247
+ | `cvss_score` | number \| null | CVSS base score (prefers v3.1 > v3.0) |
248
+ | `cvss_vector` | string \| null | CVSS vector string |
249
+ | `cvss_version` | string \| null | CVSS version (`3.1`, `3.0`) |
250
+ | `cvss_severity` | string \| null | CVSS severity label |
251
+ | `epss_score` | number \| null | EPSS probability (0–1); null if not yet scored |
252
+ | `epss_percentile` | number \| null | EPSS percentile (0–1); null if not yet scored |
253
+ | `kev_listed` | boolean | `true` if in CISA KEV |
254
+ | `kev_date_added` | string \| null | Date added to KEV; null if not listed |
255
+ | `kev_due_date` | string \| null | CISA remediation due date; null if not listed |
256
+ | `kev_ransomware_use` | string \| null | Ransomware use flag from KEV; null if not listed |
257
+ | `exposure_tier` | string | Derived tier: `LOW`, `MEDIUM`, `HIGH`, `CRITICAL` |
258
+ | `actionable` | boolean | `true` if tier ≥ `actionable_min_tier` |
259
+
260
+ ---
261
+
262
+ ## Exposure Tier Rules
263
+
264
+ Tiers are evaluated in priority order; first match wins.
265
+
266
+ | Tier | Condition |
267
+ |------|-----------|
268
+ | `CRITICAL` | `kev_listed = true` |
269
+ | `HIGH` | `epss_score >= 0.10` OR `cvss_score >= 9.0` |
270
+ | `MEDIUM` | `epss_score >= 0.01` OR `cvss_score >= 7.0` |
271
+ | `LOW` | everything else |
272
+
273
+ **Null handling:** null CVSS or EPSS scores evaluate as `false` in threshold comparisons — they never qualify for a higher tier.
274
+
275
+ **`actionable`:** `true` when `exposure_tier` ≥ `actionable_min_tier` (default: `medium`), so tiers `{MEDIUM, HIGH, CRITICAL}` are actionable by default.
276
+
277
+ **Threshold ordering:** `LOW < MEDIUM < HIGH < CRITICAL`
278
+
279
+ ---
280
+
281
+ ## Code Flow
282
+
283
+ ```mermaid
284
+ flowchart TD
285
+ A[oebc generate] --> B[fetch_nvd_cves\nPaginated NVD API]
286
+ A --> C[fetch_epss_scores\nPaginated EPSS API]
287
+ A --> D[fetch_kev\nCISA KEV JSON]
288
+ B --> E[build_catalog\nJoin by CVE ID]
289
+ C --> E
290
+ D --> E
291
+ E --> F[compute_exposure_tier\nKEV → CRITICAL\nEPSS/CVSS → HIGH/MEDIUM\nelse → LOW]
292
+ F --> G[compute_actionable\ntier >= actionable_min_tier]
293
+ G --> H[Write JSON\nindent=2]
294
+ H --> I[oebc_full_catalog_enriched.json]
295
+ ```
296
+
297
+ ---
298
+
299
+ ## Project Structure
300
+
301
+ ```
302
+ oebc/
303
+ ├── .github/
304
+ │ ├── dependabot.yml # Weekly grouped pip and Actions updates
305
+ │ └── workflows/
306
+ │ ├── main-release.yml # Daily catalog generation → publish to openastra.org
307
+ │ ├── schema-release.yml # Schema versioning → publish schema to openastra.org
308
+ │ ├── pypi-publish.yml # Publish to PyPI on v*.*.* tags
309
+ │ └── develop.yml # CI on PRs and pushes to develop branch
310
+ ├── spec/
311
+ │ ├── VERSION # Semver schema version (e.g., 0.1.0)
312
+ │ └── schemas/
313
+ │ ├── oebc-v0.1.json # JSON Schema Draft 2020-12
314
+ │ └── oebc-v0.1.yaml # YAML equivalent
315
+ ├── src/oebc/
316
+ │ ├── __init__.py # Package version
317
+ │ ├── __main__.py # Entry point
318
+ │ ├── cli.py # CLI dispatcher (fetch/generate)
319
+ │ ├── fetch.py # Download pre-built catalog from openastra.org
320
+ │ ├── generate.py # Core: fetch NVD+EPSS+KEV, join, enrich, output
321
+ │ └── urls.py # Default source URLs
322
+ ├── tests/
323
+ │ ├── test_cli.py
324
+ │ ├── test_fetch.py
325
+ │ ├── test_generate.py
326
+ │ ├── test_schema_validation.py
327
+ │ └── test_version.py
328
+ ├── CITATION.cff
329
+ ├── CONTRIBUTING.md
330
+ ├── LICENSE
331
+ ├── Makefile
332
+ ├── pyproject.toml
333
+ ├── README.md
334
+ └── SECURITY.md
335
+ ```
336
+
337
+ > **Note:** No `baseline/` or `historical/` directory. Catalog artifacts are generated ephemerally in CI and pushed directly to openastra.org — the repo stays clean.
338
+
339
+ ---
340
+
341
+ ## Automation
342
+
343
+ OEBC ships four GitHub Actions workflows:
344
+
345
+ ### `develop.yml` — Development CI
346
+
347
+ Triggered on push to `develop` and pull requests targeting `develop` or `main`.
348
+
349
+ - `make check`: lint with ruff, then the full test suite with 100% coverage enforced
350
+ - Runs on Python 3.11, 3.12, 3.13
351
+
352
+ ### `main-release.yml` — Daily Catalog Generation
353
+
354
+ Triggered on schedule (daily 06:00 UTC), push to `main`, and manual dispatch.
355
+
356
+ 1. Test across Python 3.11, 3.12, 3.13
357
+ 2. `oebc generate --out oebc_full_catalog_enriched.json` (ephemeral)
358
+ 3. gzip the catalog and push it as `public/oebc/catalog/v0.1/latest.json.gz` to the openastra.org site repository (served at `openastra.org/oebc/catalog/v0.1/latest.json.gz`)
359
+ 4. If the catalog changed, create a GitHub Release tagged `baseline-YYYY-MM-DD` with the snapshot attached as `oebc-catalog-YYYY-MM-DD.json.gz`
360
+
361
+ Requires secret: `OEBC_DEPLOY_TOKEN`
362
+
363
+ ### `schema-release.yml` — Schema Publishing
364
+
365
+ Triggered on push to `main` affecting `spec/**`.
366
+
367
+ 1. Validate `spec/VERSION` (semver); abort if tag already exists
368
+ 2. Publish schema files to `openastra.org/oebc/schema/v{MAJOR.MINOR}/`
369
+ 3. Create GitHub Release tagged `spec-v{VERSION}` with schema files attached
370
+
371
+ Requires secret: `OEBC_DEPLOY_TOKEN`
372
+
373
+ ### `pypi-publish.yml` — PyPI Publishing
374
+
375
+ Triggered on push of any `v*.*.*` tag.
376
+
377
+ 1. Build Python package
378
+ 2. Publish to PyPI via OIDC Trusted Publishing (no API token required)
379
+
380
+ ---
381
+
382
+ ## Development
383
+
384
+ ```bash
385
+ # Install with dev dependencies + pre-commit hooks
386
+ make install-dev
387
+
388
+ # Run tests
389
+ make test
390
+
391
+ # Run tests with 100% coverage report
392
+ make test-cov
393
+
394
+ # Lint
395
+ make lint
396
+
397
+ # Auto-format
398
+ make format
399
+
400
+ # Lint + test-cov
401
+ make check
402
+
403
+ # Generate catalog locally
404
+ make generate
405
+ ```
406
+
407
+ ---
408
+
409
+ ## Data Sources
410
+
411
+ | Source | URL | What it provides |
412
+ |--------|-----|-----------------|
413
+ | NVD CVE API | `https://services.nvd.nist.gov/rest/json/cves/2.0` | All CVEs: description, CVSS score/vector/version/severity, CWE IDs, dates |
414
+ | EPSS API | `https://api.first.org/data/v1/epss` | Exploitation probability score (0–1) + percentile per CVE |
415
+ | CISA KEV | `https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json` | Known exploited CVEs with `dateAdded`, `dueDate`, ransomware use |
416
+
417
+ All sources are fetched live at generation time. No local data files committed to the repo.
418
+
419
+ ---
420
+
421
+ ## Contributing
422
+
423
+ To report a wrong tier, a wrong EPSS or KEV join or a missing CVE, or to send
424
+ a change, see **[CONTRIBUTING.md](CONTRIBUTING.md)**. It covers the dev setup,
425
+ the 100% coverage rule, how tier rules change and how the schema is versioned.
426
+
427
+ ---
428
+
429
+ ## Security
430
+
431
+ Please don't report security problems in a public issue. Use
432
+ [**Report a vulnerability**](https://github.com/sadayamuthu/oebc/security/advisories/new)
433
+ on the Security tab; see **[SECURITY.md](SECURITY.md)** for what's in scope.
434
+
435
+ ---
436
+
437
+ ## License
438
+
439
+ [MIT License](LICENSE)
440
+
441
+ The license covers the code in this repository. The catalog republishes NVD,
442
+ FIRST EPSS and CISA KEV data, which stays under its providers' terms; see
443
+ [Upstream data](CONTRIBUTING.md#upstream-data).
444
+
445
+ ---
446
+
447
+ <div align="center">
448
+
449
+ [Catalog](https://openastra.org/oebc/catalog/v0.1/latest.json.gz) · [Schema](https://openastra.org/oebc/schema/v0.1/oebc.json) · [PyPI](https://pypi.org/project/oebc/) · [openastra.org](https://openastra.org)
450
+
451
+ </div>
@@ -0,0 +1,12 @@
1
+ oebc/__init__.py,sha256=cpF0Dixb94fOpfg8uXerg-k_pShDx-Kbdtsr5w2HRBo,176
2
+ oebc/__main__.py,sha256=jUDiCGRIhJvfwEslxaIxuuBq7RQwE_t_z0CFJRVfNl8,81
3
+ oebc/cli.py,sha256=BZ28pYNVRqg8uh9fCJRswt_opXpQ6nnqCQp1wIPAWpw,1312
4
+ oebc/fetch.py,sha256=I6juIlNEFKG2_RWc9S28nVdCPXGb8dE_6h8FUzpHVJ4,785
5
+ oebc/generate.py,sha256=811hq43RWjzc0Sc615J_eFvL8z3ZuRw9bgT2Nro7t0E,11664
6
+ oebc/urls.py,sha256=8Grp92uxWJ_rwdm2Ix1ClEFBfwBrSn87rkdKwDwJT14,783
7
+ oebc-0.1.0.dist-info/licenses/LICENSE,sha256=Rmq4mhzHm3g4XKkcf_IFAbiW9FPkQ2zeBx0yomCC7CY,1080
8
+ oebc-0.1.0.dist-info/METADATA,sha256=yh4g8qMcO9zldI8A5uCV9pbJupl0yfhRuCL79lY6D08,15813
9
+ oebc-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ oebc-0.1.0.dist-info/entry_points.txt,sha256=eCalFxww6KksZLKnWvDTC9ajXkwJyJ12pJnrxbeFAWY,39
11
+ oebc-0.1.0.dist-info/top_level.txt,sha256=8GXueB-P_e5IEnbqJTboNg3Tydzkr_VgVnruLzmH244,5
12
+ oebc-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ oebc = oebc.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Karthikeyan Sadayamuthu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ oebc