Skip to main content
Security Fundamentals · lesson 3 of 8
Security Fundamentals beginner Lesson 3 of 8

Threat, Vulnerability, Risk: a Risk Register in 30 Lines

Score risks as likelihood x impact in a Python risk register, catch the averaging bug that hides a 25/25 risk, and decide treatment against an appetite.

By SkillByExample · Published

security risk-assessment risk-register threat-modeling grc

A threat is what could go wrong, a vulnerability is the weakness that lets it, and risk is how likely that is multiplied by how much it would hurt. Writing those three down for every asset gives you a risk register, the basic document behind every security budget conversation. This lesson builds one in plain Python, then shows the aggregation mistake that sends the money to the wrong place. Every example was run with Python 3.12.15 in the python:3.12-slim Docker image.

A risk register is a scored list

Each row names an asset, a threat, the vulnerability it would use, and two judgements on a 1-5 scale: likelihood and impact. Their product is the score.

# risks.py
RISKS = [  # asset, threat, vulnerability, likelihood 1-5, impact 1-5
    ("payments-db",    "ransomware",        "no offline backup",       5, 5),
    ("payments-db",    "disk failure",      "single replica",          1, 2),
    ("payments-db",    "slow query",        "missing index",           1, 1),
    ("payments-db",    "admin typo",        "no change review",        2, 1),
    ("payments-db",    "expired cert",      "manual renewal",          1, 2),
    ("marketing-site", "defacement",        "outdated CMS plugin",     3, 3),
    ("marketing-site", "credential reuse",  "no MFA on CMS",           3, 3),
    ("marketing-site", "DDoS",              "no CDN",                  2, 4),
]

def band(score):
    return "HIGH" if score >= 15 else "MEDIUM" if score >= 8 else "LOW"

if __name__ == "__main__":
    scored = sorted(RISKS, key=lambda r: r[3] * r[4], reverse=True)
    for asset, threat, vuln, l, i in scored:
        s = l * i
        print(f"{s:>2} {band(s):<6} {asset:<14} {threat:<16} via {vuln}")
$ python risks.py
25 HIGH   payments-db    ransomware       via no offline backup
 9 MEDIUM marketing-site defacement       via outdated CMS plugin
 9 MEDIUM marketing-site credential reuse via no MFA on CMS
 8 MEDIUM marketing-site DDoS             via no CDN
 2 LOW    payments-db    disk failure     via single replica
 2 LOW    payments-db    admin typo       via no change review
 2 LOW    payments-db    expired cert     via manual renewal
 1 LOW    payments-db    slow query       via missing index

Sorted row by row, the register is unambiguous: ransomware against the payments database, at 25 out of a possible 25, is the first thing to fix. Notice that the threat column never mentions a fix. You can’t remove “ransomware” from the world. What you can change is the vulnerability (“no offline backup”), and through it the likelihood or the impact. The other examples import RISKS and band from this file, so keep it in the same directory.

The bug: averaging per asset hides the critical risk

Managers usually want one number per system, not a list of rows. Averaging each asset’s scores is the obvious way to get it.

# by_asset_avg.py
from collections import defaultdict
from risks import RISKS

scores = defaultdict(list)
for asset, _threat, _vuln, l, i in RISKS:
    scores[asset].append(l * i)

ranking = sorted(scores, key=lambda a: sum(scores[a]) / len(scores[a]), reverse=True)
print("Fix first (by average risk):")
for n, asset in enumerate(ranking, 1):
    avg = sum(scores[asset]) / len(scores[asset])
    print(f"  {n}. {asset:<14} avg={avg:.1f}  scores={scores[asset]}")
$ python by_asset_avg.py
Fix first (by average risk):
  1. marketing-site avg=8.7  scores=[9, 9, 8]
  2. payments-db    avg=6.4  scores=[25, 2, 1, 2, 2]

The marketing site now comes first, and the payments database, with the only HIGH risk in the register, comes second. Four trivial rows (a slow query, an expired certificate) pulled the 25 down to an average of 6.4. The result gets worse the more carefully a team records small risks. Thorough documentation makes the critical system look safer, which is exactly backwards.

The fix: rank by the worst risk

An asset is as exposed as its worst unmitigated risk, so rank by the maximum and say which risk drives it.

# by_asset_max.py
from collections import defaultdict
from risks import RISKS, band

worst = defaultdict(lambda: (0, ""))
for asset, threat, _vuln, l, i in RISKS:
    worst[asset] = max(worst[asset], (l * i, threat))

ranking = sorted(worst, key=lambda a: worst[a], reverse=True)
print("Fix first (by worst risk):")
for n, asset in enumerate(ranking, 1):
    score, threat = worst[asset]
    print(f"  {n}. {asset:<14} max={score:<2} {band(score):<6} driven by {threat}")
$ python by_asset_max.py
Fix first (by worst risk):
  1. payments-db    max=25 HIGH   driven by ransomware
  2. marketing-site max=9  MEDIUM driven by defacement

The order flips back, and each line now carries the reason for its rank. Storing (score, threat) tuples lets max compare the score first, so the threat travels with it for free. Any summary of a register should keep this property: a single HIGH must never disappear inside an aggregate. Showing the band next to the number helps too, because readers react to “HIGH” faster than to “25”.

Treatment: does the control bring it under appetite?

The code needs a risk threshold (risk tolerance), set from the organisation’s appetite: the broad amount of risk it is willing to accept. Any score above the threshold must be treated. A control is judged by the residual risk it leaves, not by whether it sounds strong.

# treatment.py
from risks import RISKS, band

APPETITE = 12          # anything scoring above this must be treated
CONTROLS = {           # threat -> (control, new likelihood, new impact)
    "ransomware": ("immutable offline backups, tested restores", 5, 2),
}

for asset, threat, _vuln, l, i in RISKS:
    if l * i <= APPETITE:
        continue
    control, nl, ni = CONTROLS.get(threat, ("NONE", l, i))
    residual = nl * ni
    verdict = "accept" if residual <= APPETITE else "STILL ABOVE APPETITE"
    print(f"{asset}/{threat}: {l * i} {band(l * i)} -> {control}")
    print(f"  residual {nl}x{ni}={residual} {band(residual)}: {verdict}")
$ python treatment.py
payments-db/ransomware: 25 HIGH -> immutable offline backups, tested restores
  residual 5x2=10 MEDIUM: accept

The backup control leaves likelihood at 5. Ransomware is just as likely to arrive. What drops is the impact, from 5 to 2, because a tested restore turns a business-ending event into a bad day. Backups restore availability, though. They don’t undo data that was stolen first and used for extortion. Recording likelihood and impact separately shows which one a control moves, so nobody claims that backups “prevent” ransomware. The residual of 10 is within the threshold of 12, so the risk can be formally accepted. Choosing the threshold is a business decision, and the third practice task shows how much it changes the outcome.

Practice

  1. Rank by total instead. Someone suggests summing scores per asset instead of averaging. Add an intranet asset with twelve minor risks scored 2 x 2, and rank by total. Does the sum hide the ransomware risk too?
Solution
# by_asset_sum.py
from collections import defaultdict
from risks import RISKS

risks = RISKS + [("intranet", f"minor issue {n}", "various", 2, 2) for n in range(12)]

totals = defaultdict(int)
for asset, _threat, _vuln, l, i in risks:
    totals[asset] += l * i

for n, asset in enumerate(sorted(totals, key=totals.get, reverse=True), 1):
    print(f"  {n}. {asset:<14} total={totals[asset]}")
$ python by_asset_sum.py
  1. intranet       total=48
  2. payments-db    total=32
  3. marketing-site total=26

Yes. A sum rewards the asset with the most rows, so twelve LOW issues outrank one HIGH. Every aggregate that blends scores (mean, sum, median) has some register that defeats it. Rank by the maximum, then break ties with the count of HIGH and MEDIUM risks.

  1. Add a risk at the boundary. Append a dev-laptop theft risk via an unencrypted disk, rated likelihood 3 and impact 4. With an appetite of 12, is it treated?
Solution
# laptop.py
from risks import RISKS, band

RISKS.append(("dev-laptop", "theft", "unencrypted disk", 3, 4))

APPETITE = 12
for asset, threat, vuln, l, i in RISKS:
    if asset == "dev-laptop":
        s = l * i
        action = "treat" if s > APPETITE else "accept (at appetite, not above)"
        print(f"{asset}/{threat} via {vuln}: {l}x{i}={s} {band(s)} -> {action}")
$ python laptop.py
dev-laptop/theft via unencrypted disk: 3x4=12 MEDIUM -> accept (at appetite, not above)

A score of exactly 12 sits on the line, and the code accepts it because the rule is “above 12”. Whether the boundary is > or >= is a policy decision. Write it down, because a reviewer will ask. Full-disk encryption is cheap enough that most teams treat this risk anyway.

  1. Lower the appetite. Set APPETITE = 8 and rerun the treatment logic. Which risks now need action, and is the backup control still enough?
Solution
# appetite8.py
from risks import RISKS

APPETITE = 8
CONTROLS = {"ransomware": ("immutable offline backups, tested restores", 5, 2)}

for asset, threat, _vuln, l, i in RISKS:
    if l * i <= APPETITE:
        continue
    control, nl, ni = CONTROLS.get(threat, ("NONE", l, i))
    residual = nl * ni
    verdict = "accept" if residual <= APPETITE else "STILL ABOVE APPETITE"
    print(f"{asset}/{threat}: {l * i} -> {control}; residual {residual}: {verdict}")
$ python appetite8.py
payments-db/ransomware: 25 -> immutable offline backups, tested restores; residual 10: STILL ABOVE APPETITE
marketing-site/defacement: 9 -> NONE; residual 9: STILL ABOVE APPETITE
marketing-site/credential reuse: 9 -> NONE; residual 9: STILL ABOVE APPETITE

Three risks now need action, and the backup control no longer clears the bar on its own. Ransomware needs a second control that lowers likelihood, such as MFA on remote access or faster patching. Running the register is how you find that out before the auditor does.

Frequently Asked Questions

What is the difference between a threat, a vulnerability and a risk?
A threat is something that could cause harm, such as ransomware or a disk failure. A vulnerability is a weakness that lets the threat succeed, such as having no offline backup. A risk combines them with a consequence: how likely the threat is to exploit the weakness, and how bad the impact would be. You can't remove threats, but you can fix vulnerabilities and so reduce risk.
How is a risk score calculated?
The most common qualitative method multiplies likelihood by impact, each rated on a scale such as 1 to 5, giving a score from 1 to 25. Scores are then grouped into bands such as low, medium and high. The numbers are judgements, not measurements, so their main value is ranking risks consistently and showing what to fix first.
Why should you not average risk scores per asset?
An average lets many small risks dilute one critical one. An asset with a single 25 out of 25 risk and four trivial risks averages 6.4, which can rank it below an asset whose risks are all moderate. Rank assets by their worst risk instead, and keep the full list visible so nothing high scoring is hidden by aggregation.
What is risk appetite and residual risk?
Risk appetite is the broad amount of risk an organisation is willing to accept in pursuit of its goals. Teams turn it into a risk threshold (risk tolerance) that a register can apply, for example treating any score above 12. Residual risk is what remains after a control is applied. If the residual score is still above the threshold, the risk needs another control, or a named person has to formally accept it.
Does a security control always reduce likelihood?
No. Some controls reduce likelihood, such as patching or multi-factor authentication. Others reduce impact, such as tested offline backups, which don't stop ransomware from arriving but limit the damage it can do. Scoring likelihood and impact separately shows which one a control actually changes.