Combining CVSS, Asset Criticality, Exploitability, and Fix Cost with Python
Every security team faces the same uncomfortable arithmetic: the backlog of vulnerabilities grows faster than the patching capacity. Sorting by CVSS score alone is the most common reaction, but it ignores three things that matter just as much: how important the affected asset is, how likely the flaw is to be exploited in the wild, and how many engineer-hours the fix will consume.
In this article we turn patch prioritization into a mathematical optimization problem and solve it in two complementary ways:
- Which vulnerabilities should we fix in this maintenance window? This is a 0-1 knapsack problem, solved exactly by dynamic programming.
- In what order should we fix them? This is a weighted completion-time scheduling problem, solved exactly by Smith’s ratio rule.
1. Problem Setting
Suppose we manage $N = 30$ open vulnerabilities. For each vulnerability $i$ we know:
- $s_i$: the CVSS base score, in $[0, 10]$
- $a_i$: asset criticality, an integer from 1 (lab server) to 5 (core production system)
- $e_i$: exploitation probability (EPSS-like), in $(0, 1)$
- $c_i$: fix cost in engineer-hours (patching, testing, rollout, and rollback preparation)
Risk score
We define the risk carried by an unpatched vulnerability as
$$
R_i = 100 \cdot \frac{s_i}{10} \cdot \frac{a_i}{5} \cdot e_i .
$$
This is a normalized expected-loss proxy: severity, multiplied by asset importance, multiplied by the probability that someone actually exploits it.
Part A: Choosing what to patch in one window
With a patch budget of $B$ engineer-hours, let $x_i \in {0, 1}$ indicate whether vulnerability $i$ is patched. We maximize the total risk removed:
$$
\max_{x} \sum_{i=1}^{N} R_i x_i \quad \text{subject to} \quad \sum_{i=1}^{N} c_i x_i \le B, \quad x_i \in {0,1}.
$$
Because the costs are integers, the dynamic programming recursion is exact:
$$
V(i, b) = \max\Big( V(i-1, b),; V(i-1, b - c_i) + R_i \Big), \qquad V(0, b) = 0 .
$$
The optimum is $V(N, B)$, and the chosen set is recovered by backtracking.
Part B: Choosing the order of patching
Now suppose we must eventually patch everything with a single team working serially. If vulnerability $i$ is finished at time $C_i$, it contributes risk exposure $R_i C_i$ (risk multiplied by the time it stayed open). The total cumulative exposure is
$$
J(\pi) = \sum_{i=1}^{N} R_i , C_i(\pi),
$$
and the residual risk at time $t$ is
$$
\rho(t) = \sum_{i:, C_i > t} R_i , \qquad J = \int_0^{\infty} \rho(t),dt .
$$
Minimizing $J$ is the classical single-machine problem $1 ,|, \sum w_j C_j$, and Smith’s rule is provably optimal: process the items in descending order of the ratio
$$
\frac{R_i}{c_i}.
$$
In words, fix first whatever removes the most risk per engineer-hour.
2. Full Source Code (Single File)
1 | import numpy as np |
3. Code Walkthrough
Data generation
The backlog is synthetic but realistic in shape. CVSS scores are uniform between 3.1 and 9.9. Asset criticality is an integer from 1 to 5. Exploit probabilities come from a Beta(0.6, 3.0) distribution, which mimics real EPSS data: most vulnerabilities have a tiny chance of exploitation, and a small minority are very likely to be attacked. Fix costs range from 1 to 16 engineer-hours. A fixed random seed makes the experiment reproducible. The array risk implements the formula $R_i = 100 \cdot \frac{s_i}{10} \cdot \frac{a_i}{5} \cdot e_i$, and density is the ratio $R_i / c_i$, the “risk removed per engineer-hour.”
Exact dynamic programming
knapsack_table builds the full table $V(i, b)$. The inner loop over budgets is vectorized with NumPy. Instead of looping over every capacity $b$ in Python, the whole row update is performed with array slices:
candholds the value of taking item $i$ for every capacity at once.betteris a Boolean mask that marks where taking the item beats skipping it.np.wherewrites the better value, andtakeremembers the decision for backtracking.
This reduces the work to $O(NB)$ elementary operations executed in compiled code, so even backlogs with thousands of vulnerabilities and budgets in the thousands of hours remain interactive. reconstruct walks backward through the take table to recover which vulnerabilities form the optimal set.
Heuristic baselines
Three common practical strategies are implemented with the same greedy_pick routine, differing only in sort order:
- CVSS rank is what many teams do today.
- Risk score rank uses the combined risk $R_i$ but ignores cost.
- Risk / cost ratio uses the density $R_i / c_i$, the classic knapsack greedy.
Each heuristic fills the budget in its own order and skips items that no longer fit. The DP solution is guaranteed to be at least as good as all of them, and the program prints a check of this fact.
Sequencing with Smith’s rule
weighted_completion computes $J = \sum R_i C_i$ for any order by accumulating the completion time. residual_curve converts an order into the step function $\rho(t)$, whose area is exactly $J$. The code then compares four things: the CVSS order, the risk-score order, Smith’s ratio order, and 3000 random permutations. As a sanity check on optimality, it also verifies that no adjacent swap in Smith’s order lowers $J$, which is the exchange argument that proves the rule.
Dark-themed combined figure
All six panels are drawn in a single figure with add_gridspec, so one plt.show() produces the whole picture.
4. Execution Results
======================================================================== Vulnerability backlog (budget = 60 engineer-hours) ======================================================================== ID CVSS Crit EPSS Cost Risk R/c Patch now VULN-01 4.3 5 0.073 8 3.14 0.392 - VULN-02 7.5 2 0.032 10 0.96 0.096 - VULN-03 6.3 5 0.059 12 3.72 0.310 - VULN-04 5.6 5 0.010 16 0.56 0.035 - VULN-05 5.5 5 0.149 1 8.20 8.195 YES VULN-06 8.5 4 0.010 15 0.68 0.045 - VULN-07 9.3 3 0.119 9 6.64 0.738 - VULN-08 4.3 4 0.513 2 17.65 8.824 YES VULN-09 7.5 2 0.010 14 0.30 0.021 - VULN-10 5.1 2 0.115 14 2.35 0.168 - VULN-11 9.7 3 0.151 5 8.79 1.758 YES VULN-12 9.4 5 0.661 3 62.13 20.711 YES VULN-13 7.4 4 0.124 5 7.34 1.468 YES VULN-14 8.2 3 0.076 13 3.74 0.288 - VULN-15 6.6 3 0.141 15 5.58 0.372 - VULN-16 8.7 3 0.158 5 8.25 1.650 YES VULN-17 6.1 3 0.166 8 6.08 0.759 - VULN-18 5.4 5 0.038 12 2.05 0.171 - VULN-19 5.0 1 0.086 3 0.86 0.287 - VULN-20 4.6 2 0.040 12 0.74 0.061 - VULN-21 6.7 3 0.058 4 2.33 0.583 YES VULN-22 6.0 4 0.120 14 5.76 0.411 - VULN-23 7.6 3 0.381 3 17.37 5.791 YES VULN-24 3.2 2 0.059 16 0.76 0.047 - VULN-25 6.1 1 0.911 12 11.11 0.926 YES VULN-26 5.6 2 0.010 5 0.22 0.045 - VULN-27 4.4 1 0.110 13 0.97 0.074 - VULN-28 7.1 4 0.314 11 17.84 1.621 YES VULN-29 6.1 3 0.297 8 10.87 1.359 YES VULN-30 5.1 2 0.020 12 0.41 0.034 - ======================================================================== Part A: which vulnerabilities to patch in this window ======================================================================== Total risk in backlog: 217.38 CVSS rank risk removed = 123.14 ( 56.6% of total), hours used = 59, items = 9 Risk score rank risk removed = 171.88 ( 79.1% of total), hours used = 59, items = 11 Risk / cost ratio risk removed = 171.88 ( 79.1% of total), hours used = 59, items = 11 DP optimal risk removed = 171.88 ( 79.1% of total), hours used = 59, items = 11 DP >= every heuristic: True DP-selected IDs: VULN-05, VULN-08, VULN-11, VULN-12, VULN-13, VULN-16, VULN-21, VULN-23, VULN-25, VULN-28, VULN-29 ======================================================================== Part B: in which order to patch everything (single team, serial work) ======================================================================== Total patching time: 280 engineer-hours Cumulative exposure J (CVSS order) : 19637.8 Cumulative exposure J (Risk score order) : 9076.3 Cumulative exposure J (Smith ratio rule) : 8146.9 Cumulative exposure J (random, mean) : 31241.3 Cumulative exposure J (random, best) : 15199.9 Reduction vs CVSS order : 58.5% Reduction vs random mean: 73.9% Smith's order beats every adjacent swap: True First 10 in Smith order: VULN-12, VULN-08, VULN-05, VULN-23, VULN-11, VULN-16, VULN-28, VULN-13, VULN-29, VULN-25

5. How to Read the Graphs
Top left: 3D scatter of the backlog. Each point is a vulnerability positioned by CVSS score, exploit probability, and fix cost. Colour encodes risk per engineer-hour. The star markers are the vulnerabilities selected by the exact optimizer. Notice that the selected points do not simply sit at the far right (highest CVSS). They concentrate where exploit probability is high and cost is low, which is exactly the region where each hour of work removes the most risk. High-CVSS items with a near-zero exploit probability, or items that are extremely expensive to fix, tend to be deferred.
Top middle: strategy comparison. The bars show the total risk removed within the same $B = 60$ hour budget. The DP bar is the guaranteed maximum, and the other three show how much a simpler rule leaves on the table. The CVSS-only ranking typically performs worst because it ignores both asset importance and exploitability. The risk-per-hour greedy rule usually lands very close to the optimum, which is why it is such a popular practical rule, but only the dynamic program is guaranteed to hit the exact maximum.
Top right: value of additional capacity. This curve answers a management question: how much risk disappears if we add more patching hours? The DP curve always lies on or above the others. Its slope is steep at small budgets, where the cheap, high-risk items are picked up, and flattens at large budgets, where only low-value items remain. The dashed line marks the current budget, and the vertical distance between the curves there is the benefit of optimizing rather than ranking by CVSS.
Bottom left: 3D value surface. The surface $V(k, b)$ shows the best achievable risk reduction as a function of the budget and the number of top-ranked candidates considered. It rises along both axes and saturates in both directions. Along the candidate axis, the surface flattens quickly because the items ranked highest by density deliver most of the value. Along the budget axis, it flattens once the high-value items are already covered. The flat region is the zone of diminishing returns, where additional effort buys little.
Bottom middle: residual risk over time. Each curve shows the risk still open as the campaign progresses. Smith’s rule drops fastest at the beginning, because it front-loads the items with the best risk-to-effort ratio, so the area under its curve, which equals $J$, is the smallest possible. The CVSS-ordered curve stays higher for longer, because early hours are spent on expensive items whose risk reduction is modest.
Bottom right: Smith’s rule against random schedules. The histogram shows the cumulative exposure of 3000 random patch orders. The yellow line for Smith’s rule lies to the left of every random schedule, which illustrates its optimality, and the dashed lines show how far the CVSS and risk-score orders fall from it.
6. Practical Takeaways
- Rank by value per effort, not by severity. CVSS alone is a poor proxy for real risk. Multiplying by asset criticality and exploit probability reorders the backlog dramatically.
- Use the knapsack formulation for one maintenance window, where the question is what fits, and the ratio rule for the order of work.
- The model is easy to extend. Dependencies between patches can be added as precedence constraints, reboot windows as grouped costs, and compensating controls such as WAF rules as partial risk reduction.
- Refresh the inputs continuously. EPSS probabilities and asset inventories change daily, and since the DP runs in milliseconds, the prioritization can be recomputed on every update.











