Vulnerability Management and CVSS
From "The Scanner Found 4,000 Issues" to "Fixed"
The first scan of a real environment returns thousands of findings, most rated High or Critical, and a team that works through them in scanner order fixes very little that matters. Vulnerability management is the continuous process around the scanner: know what you own, find what is wrong with it, decide what to fix first, fix it within an agreed time, and prove it stayed fixed. A penetration test is a deep snapshot; this is the always-on pipeline between tests.
Start With an Inventory
You cannot patch a server nobody knows exists, and real incidents often start on assets missing from every list: a forgotten staging host, an old VPN appliance. Reconcile several sources: cloud provider APIs, DNS records, endpoint agents, the CMDB, and external discovery of your own domains (certificate transparency logs, DNS enumeration). For each asset record an owner, whether it is internet-facing, and what it holds. Those three fields drive prioritisation later.
Where Findings Come From
| Source | Finds | Blind spots |
|---|---|---|
| Host scans (authenticated) or agents | Missing patches, weak configuration | Hosts you never enrolled |
| Network scans (unauthenticated) | Exposed services, TLS problems | Guesses versions from banners |
| Image and dependency scanners (Trivy, Grype, pip-audit) | Vulnerable packages (supply chain) | Whether the code is ever loaded |
| Cloud posture tools | Public buckets, open security groups | Application flaws |
| Vendor advisories, pentests, bug bounty | Appliances, logic and authorization flaws | Someone must read and file them |
Prefer authenticated scans. Debian, Ubuntu and Red Hat backport security fixes without changing the upstream version number, so a banner-based match on an Apache version string is a guess; authenticated scans compare against the distribution's own advisories. And deduplicate first: the same OpenSSL issue reported by three tools on forty hosts is one task (upgrade the base image), not 120 tickets.
CVSS: What the Number Actually Measures
The Common Vulnerability Scoring System describes a vulnerability as a vector of metrics and converts it to a 0 to 10 score. The CVSS v3.1 base metrics are Attack Vector (Network, Adjacent, Local, Physical), Attack Complexity, Privileges Required, User Interaction, Scope (does impact cross a security boundary?) and the Confidentiality, Integrity and Availability impacts. The formula is public, and implementing it removes the mystery:
import math
W = {"AV": {"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.2},
"AC": {"L": 0.77, "H": 0.44},
"UI": {"N": 0.85, "R": 0.62},
"CIA": {"H": 0.56, "L": 0.22, "N": 0.0}}
PR = {"U": {"N": 0.85, "L": 0.62, "H": 0.27}, # scope unchanged
"C": {"N": 0.85, "L": 0.68, "H": 0.5}} # scope changed
def roundup(x): # CVSS 3.1 "round up to one decimal"
i = round(x * 100000)
return i / 100000 if i % 10000 == 0 else (math.floor(i / 10000) + 1) / 10
def cvss31_base(vector: str) -> float:
m = dict(part.split(":") for part in vector.split("/")[1:])
iss = 1 - ((1 - W["CIA"][m["C"]]) * (1 - W["CIA"][m["I"]]) * (1 - W["CIA"][m["A"]]))
if m["S"] == "U":
impact = 6.42 * iss
else:
impact = 7.52 * (iss - 0.029) - 3.25 * (iss - 0.02) ** 15
exploitability = (8.22 * W["AV"][m["AV"]] * W["AC"][m["AC"]]
* PR[m["S"]][m["PR"]] * W["UI"][m["UI"]])
if impact <= 0:
return 0.0
total = impact + exploitability if m["S"] == "U" else 1.08 * (impact + exploitability)
return roundup(min(total, 10))
print(cvss31_base("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H")) # 9.8
print(cvss31_base("CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N")) # 6.1
print(cvss31_base("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H")) # 10.0
print(cvss31_base("CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H")) # 7.8
These are recognisable shapes: 9.8 is the classic unauthenticated remote code execution, 6.1 a typical reflected XSS (needs a click, and impact crosses into the victim's browser, so scope changes), 10.0 is the vector of Log4Shell (CVE-2021-44228), and 7.8 a local privilege escalation. Read the vector, not just the number: PR:L means the attacker needs an account, AV:L that they are already on the machine.
CVSS v4.0 (FIRST, 2023) replaces Scope with separate impacts on the vulnerable and subsequent systems, adds Attack Requirements, and swaps "temporal" for a Threat group whose Exploit Maturity metric lowers the score when no exploitation is known. Labels show which groups were used (CVSS-B for base only, CVSS-BT with threat data). Expect both versions in feeds for years.
Why CVSS Alone Is a Bad Queue
- It measures severity, not risk. A 9.8 in a function you never call, on an isolated box, is less urgent than a 7.5 on your internet-facing login server. The Environmental metrics exist for this, but few teams fill them in per asset.
- Most published CVEs score High or Critical, so sorting by CVSS barely narrows the list.
- It ignores exploitation. Only a small fraction of published vulnerabilities are ever exploited in the wild, and attackers concentrate on those.
- Scores disagree. The vendor, the assigning CNA and NVD can publish different vectors, and NVD's analysis backlog since 2024 leaves some CVEs unscored for a long time.
Exploitation Signals: EPSS and KEV
EPSS (Exploit Prediction Scoring System, also from FIRST) is a daily-updated model estimating the probability that exploitation activity for a CVE will be observed in the next 30 days, with a percentile against all other CVEs. KEV is CISA's Known Exploited Vulnerabilities catalogue: CVEs with reliable evidence of exploitation in the wild, each with a required action and, for US federal agencies under Binding Operational Directive 22-01, a due date. EPSS predicts; KEV confirms. Both are free and easy to pull:
import requests
KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
EPSS_URL = "https://api.first.org/data/v1/epss"
def load_kev() -> dict:
data = requests.get(KEV_URL, timeout=30).json()
return {v["cveID"]: v for v in data["vulnerabilities"]}
def epss_scores(cves: list[str]) -> dict:
r = requests.get(EPSS_URL, params={"cve": ",".join(cves)}, timeout=30)
r.raise_for_status()
return {row["cve"]: float(row["epss"]) for row in r.json()["data"]}
cves = ["CVE-2021-44228", "CVE-2024-3094", "CVE-2016-2183"]
kev, epss = load_kev(), epss_scores(cves)
print("KEV entries:", len(kev))
for cve in cves:
entry = kev.get(cve)
print(cve, f"epss={epss.get(cve)}", f"KEV since {entry['dateAdded']}" if entry else "not in KEV")
Output when this lesson was written (EPSS changes daily and KEV grows):
KEV entries: 1729
CVE-2021-44228 epss=0.99999 KEV since 2021-12-10
CVE-2024-3094 epss=0.85974 not in KEV
CVE-2016-2183 epss=0.94697 not in KEV
Fetch both in batches during a nightly import, not per finding. For a structured alternative to ad hoc rules, CISA's SSVC (Stakeholder-Specific Vulnerability Categorization) is a decision tree over exploitation status, automatability, technical impact and mission impact that outputs Track, Track*, Attend or Act.
A Prioritisation Policy You Can Run
Turn the policy into code so it is applied the same way every night. The findings below are sample data:
from dataclasses import dataclass
from datetime import date, timedelta
@dataclass
class Finding:
id: str
asset: str
cvss: float
epss: float
kev: bool
exposed: bool # reachable from the internet
critical: bool # holds secrets, sensitive data, or deploy rights
found: date
SLA_DAYS = {"P1": 7, "P2": 30, "P3": 90, "P4": 180}
def priority(f: Finding) -> str:
if f.kev and f.exposed:
return "P1"
if f.kev or (f.epss >= 0.10 and f.exposed):
return "P2"
if f.cvss >= 7.0 and (f.exposed or f.critical):
return "P2"
return "P3" if f.cvss >= 4.0 else "P4"
findings = [
Finding("F-101", "vpn-gw appliance", 9.8, 0.94, True, True, True, date(2026, 9, 28)),
Finding("F-102", "build-07 jenkins", 9.8, 0.01, False, False, True, date(2026, 8, 15)),
Finding("F-103", "hr-db postgres", 6.5, 0.35, True, False, True, date(2026, 9, 20)),
Finding("F-104", "laptops pdfreader", 7.8, 0.00, False, False, False, date(2026, 6, 1)),
]
today = date(2026, 9, 30)
for f in sorted(findings, key=lambda f: (priority(f), -f.cvss)):
p = priority(f)
due = f.found + timedelta(days=SLA_DAYS[p])
status = "OVERDUE" if due < today else f"due {due}"
print(f"{p} {f.id} {f.asset:18} cvss={f.cvss:<4} epss={f.epss:<4} kev={f.kev!s:5} {status}")
P1 F-101 vpn-gw appliance cvss=9.8 epss=0.94 kev=True due 2026-10-05
P2 F-102 build-07 jenkins cvss=9.8 epss=0.01 kev=False OVERDUE
P2 F-103 hr-db postgres cvss=6.5 epss=0.35 kev=True due 2026-10-20
P3 F-104 laptops pdfreader cvss=7.8 epss=0.0 kev=False OVERDUE
The internet-facing VPN appliance with a KEV entry jumps the queue, which matches how many real intrusions start. The 6.5 on the HR database outranks a 7.8 because it is actively exploited, and the build server's 9.8 reaches P2 only because the asset is marked critical. The laptop finding has a 90-day SLA and is still overdue, which the report must surface. The thresholds are illustrative; agree yours with the business and write them down.
SLAs, Exceptions and VEX
An SLA turns priority into a commitment ("P1 fixed within 7 days of detection") and makes slippage visible. When a fix is impossible in time (no vendor patch, no maintenance window), record a formal exception: an owner, a reason, a compensating control (a WAF rule, disabling the feature, restricting network access) and an expiry date. An exception without an expiry becomes permanent acceptance nobody decided on.
When a finding does not apply (the vulnerable code is not compiled in or never loaded), record a VEX statement (Vulnerability Exploitability eXchange, supported by CycloneDX and OpenVEX) with status not_affected and a justification, so scanners suppress it with an audit trail instead of someone quietly muting a rule.
Tracking to Closure
- One ticket per remediation, keyed on something stable (asset plus CVE, or image plus package) so the nightly import updates tickets instead of duplicating them.
- Route to the owning team from the inventory. Findings assigned to "security" do not get fixed.
- Close only on verification by the next scan, and reopen automatically if it reappears (a rebuilt image from an old base, a restored snapshot).
- Measure mean time to remediate per priority, percentage fixed within SLA, and backlog age. Trends matter more than raw counts, which rise whenever you add a scanner.
When a KEV-listed vulnerability is found on an internet-facing system that was exposed for weeks, patching is not the end: treat it as a potential compromise and hand it to incident response to look for signs of exploitation before the fix.