Security Fundamentals · lesson 3 of 8
Beginner
4Intermediate
4Also part of Security Fundamentals
12- + Permission Bits, Ownership, and Why passwd Needs setuid from Linux
- + Database Security from SQL
- + ACL: a User Restricted to One Key Pattern and One Command from Redis
- + Kafka Security: SASL Authentication and a Real ACL Denial from Kafka
- + IAM and Least Privilege from Cloud Interviews
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + Docker Security from Docker
- + Data Security and PII: Hashing, Masking, and Tokenization from Data Engineering
- + Hashes and HMACs: What Each One Actually Proves from Cryptography
- + Broken Access Control and IDOR: Fix It in Code, Pin It with a Test from Web Security (OWASP Top 10, fix-side)
- + MCP Authorization: OAuth 2.1, CIMD and a Real Identity Provider from MCP (Model Context Protocol)
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
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
- Rank by total instead. Someone suggests summing scores per asset instead of averaging. Add an
intranetasset 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.
- Add a risk at the boundary. Append a
dev-laptoptheft 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.
- Lower the appetite. Set
APPETITE = 8and 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.