Every security team drowns in CVEs. The NVD publishes hundreds of new vulnerabilities each week, and triaging them manually is unsustainable. CVSS (Common Vulnerability Scoring System) gives you a base score — but knowing a vulnerability is rated 9.8 does not tell you whether to patch it tonight or next month.
What CVSS v3.1 actually measures
CVSS v3.1 decomposes into three metric groups:
- Base: inherent characteristics — attack vector, complexity, privileges required, user interaction, scope, and confidentiality/integrity/availability impact
- Temporal: how exploitability evolves over time (exploit code maturity, remediation level, report confidence)
- Environmental: adjustments for your specific context (modified base metrics, CIA requirements)
The base score is not a simple weighted average. It uses a non-linear formula defined in the specification, with separate impact sub-score calculations depending on whether the vulnerability scope changes. Implementing it from scratch is the best way to understand where the number comes from — and where it stops being useful.
Implementing the CVSS v3.1 base score formula
from dataclasses import dataclass
from typing import Literal
import math
# Metric weights from CVSS v3.1 specification
AV_WEIGHTS = {"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.2}
AC_WEIGHTS = {"L": 0.77, "H": 0.44}
PR_WEIGHTS = {
"N": {"changed": 0.85, "unchanged": 0.85},
"L": {"changed": 0.68, "unchanged": 0.62},
"H": {"changed": 0.50, "unchanged": 0.27},
}
UI_WEIGHTS = {"N": 0.85, "R": 0.62}
CIA_WEIGHTS = {"N": 0.00, "L": 0.22, "H": 0.56}
@dataclass
class CVSSv3Base:
attack_vector: Literal["N", "A", "L", "P"]
attack_complexity: Literal["L", "H"]
privileges_required: Literal["N", "L", "H"]
user_interaction: Literal["N", "R"]
scope: Literal["U", "C"] # Unchanged or Changed
confidentiality: Literal["N", "L", "H"]
integrity: Literal["N", "L", "H"]
availability: Literal["N", "L", "H"]
def calculate(self) -> float:
scope_key = "changed" if self.scope == "C" else "unchanged"
iss = (
1
- (1 - CIA_WEIGHTS[self.confidentiality])
* (1 - CIA_WEIGHTS[self.integrity])
* (1 - CIA_WEIGHTS[self.availability])
)
if self.scope == "U":
impact = 6.42 * iss
else:
impact = 7.52 * (iss - 0.029) - 3.25 * ((iss - 0.02) ** 15)
if impact <= 0:
return 0.0
exploitability = (
8.22
* AV_WEIGHTS[self.attack_vector]
* AC_WEIGHTS[self.attack_complexity]
* PR_WEIGHTS[self.privileges_required][scope_key]
* UI_WEIGHTS[self.user_interaction]
)
if self.scope == "U":
raw = min(impact + exploitability, 10)
else:
raw = min(1.08 * (impact + exploitability), 10)
# Round up to one decimal per specification
return math.ceil(raw * 10) / 10
Test against CVE-2021-44228 (Log4Shell), which carries base metrics AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H:
log4shell = CVSSv3Base(
attack_vector="N",
attack_complexity="L",
privileges_required="N",
user_interaction="N",
scope="C",
confidentiality="H",
integrity="H",
availability="H",
)
print(log4shell.calculate()) # -> 10.0
This matches NVD's published score exactly. The key insight: the scope == "C" branch uses a more aggressive formula because the vulnerability can affect resources beyond the vulnerable component itself.
From scores to priorities: the risk prioritizer
Raw CVSS scores treat all 10.0s identically. A 10.0 on a dev machine with no internet access is less urgent than an 8.5 on your public-facing authentication service. The prioritizer multiplies the base score by context factors you actually control.
This model considers four dimensions: CVSS score, internet exposure, whether exploit code is publicly available, and asset criticality (rated 1-5):
from dataclasses import dataclass
import math
@dataclass
class VulnerabilityRecord:
cve_id: str
cvss_score: float
asset_name: str
internet_exposed: bool
exploit_public: bool
asset_criticality: int # 1 (low) to 5 (critical)
def risk_score(self) -> float:
base = self.cvss_score / 10.0
exposure = 1.4 if self.internet_exposed else 1.0
exploit = 1.5 if self.exploit_public else 1.0
criticality = 0.5 + (self.asset_criticality * 0.2)
raw = base * exposure * exploit * criticality
return min(math.ceil(raw * 100) / 100, 10.0)
def priority(self) -> str:
score = self.risk_score()
if score >= 8.0:
return "P1 - Patch immediately"
elif score >= 6.0:
return "P2 - Patch within 72 hours"
elif score >= 4.0:
return "P3 - Patch within 2 weeks"
return "P4 - Schedule normally"
def prioritize(vulns: list[VulnerabilityRecord]) -> list[VulnerabilityRecord]:
return sorted(vulns, key=lambda v: v.risk_score(), reverse=True)
# Example
vulns = [
VulnerabilityRecord("CVE-2021-44228", 10.0, "api-gateway", True, True, 5),
VulnerabilityRecord("CVE-2023-12345", 7.8, "internal-wiki", False, False, 2),
VulnerabilityRecord("CVE-2024-99999", 9.1, "auth-service", True, False, 4),
]
for v in prioritize(vulns):
print(f"{v.cve_id} | risk={v.risk_score():.2f} | {v.priority()}")
Output:
CVE-2021-44228 | risk=10.00 | P1 - Patch immediately
CVE-2024-99999 | risk=6.55 | P2 - Patch within 72 hours
CVE-2023-12345 | risk=2.34 | P4 - Schedule normally
The internal wiki vulnerability scores 7.8 on CVSS but falls to P4 because it has no internet exposure, no public exploit, and low asset criticality. That is the useful signal a raw CVSS score cannot give you.
Pulling live CVE data from the NVD API v2
Instead of entering scores manually, fetch them directly from NVD:
import httpx
NVD_API = "https://services.nvd.nist.gov/rest/json/cves/2.0"
def fetch_cvss_score(cve_id: str, api_key: str | None = None) -> float | None:
headers = {}
if api_key:
headers["apiKey"] = api_key
resp = httpx.get(
NVD_API,
params={"cveId": cve_id},
headers=headers,
timeout=10,
)
resp.raise_for_status()
data = resp.json()
vulns = data.get("vulnerabilities", [])
if not vulns:
return None
metrics = vulns[0]["cve"].get("metrics", {})
# Prefer v3.1, fall back to v3.0
cvss_data = metrics.get("cvssMetricV31") or metrics.get("cvssMetricV30")
if not cvss_data:
return None
return cvss_data[0]["cvssData"]["baseScore"]
NVD rate-limits unauthenticated requests to roughly 5 per 30 seconds. Register for a free API key at nvd.nist.gov to unlock 50 per 30 seconds — necessary for any batch job that covers more than a handful of packages.
One practical gotcha: some CVEs only carry a v2 score (cvssMetricV2). If you need full coverage, fall back to v2 as a last resort and normalize the score, since v2 and v3 are not directly comparable.
Integrating into a vulnerability management workflow
The individual pieces above become valuable when automated. A practical nightly pipeline looks like this:
- Pull CVEs published or modified in the last 24 hours from NVD
- Filter to packages present in your SBOM (Software Bill of Materials)
- Instantiate
VulnerabilityRecordobjects using your asset inventory - Sort by
risk_score()and post P1/P2 results to Slack or Jira
Step 3 is where consistency matters most. Ad hoc criticality ratings are the most common reason a P4 becomes an incident: different teams classify the same asset differently, and the triage logic inherits the discrepancy. Formalizing asset classification early — ideally through a security hardening checklist covering your asset categories and criticality criteria — prevents that drift before it causes damage.
Two external data sources worth layering on top of CVSS:
- EPSS (Exploit Prediction Scoring System): a daily probabilistic score for exploitation likelihood in the next 30 days, available free from first.org/epss. An EPSS score above 0.5 is a reliable signal to escalate regardless of CVSS severity.
- CISA KEV (Known Exploited Vulnerabilities): a curated list of CVEs with confirmed in-the-wild exploitation. Any CVE on KEV should be treated as P1 unconditionally, regardless of your context-weighted score. The list is a free JSON feed at cisa.gov/known-exploited-vulnerabilities-catalog.
Combining CVSS, EPSS, and KEV gives you a three-layer triage. KEV entries are always P1. High-EPSS items get bumped one tier. Everything else falls through the context-weighted risk score. This covers the three main failure modes: unknown exploited vulns (KEV fixes this), low-CVSS-but-actively-exploited (EPSS fixes this), and high-CVSS-but-irrelevant-in-context (the prioritizer fixes this).
The takeaway
CVSS scores are input, not output. A base score ignores your environment, your exposure, and whether anyone is actually exploiting the vulnerability. The context-weighted risk prioritizer on top — even with four simple multipliers — produces a fundamentally more actionable result.
The code here is production-ready with minimal modification. Connect it to the NVD API for live scores, your SBOM for package filtering, and your asset inventory for criticality ratings, and you have the core of an internal vulnerability management pipeline without paying for a dedicated commercial tool. Start small: one Slack webhook for P1s, one cron job, and a spreadsheet for the asset inventory. Refine the scoring multipliers based on your own incident history over time.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)