The threat model
Ranking Findings by What Is Worth Protecting
How to rank security findings by the asset they threaten, score likelihood and impact honestly, pick a treatment for each, and say what a short model misses.
A review produces nine findings, a list of them arrives in a ticket, and the first item is whichever one the reviewer happened to write down first. Two months later none of them are fixed, because nobody could agree on which mattered. The findings were not wrong. They had no asset attached to them, so there was nothing to compare.
Ranking is not a formality you add at the end of a security review. It is the part that turns a list into a decision, and it is the part most likely to be wrong, because the honest inputs to it are business inputs. Whether an unauthenticated endpoint that leaks shipment status matters more than an authenticated one that leaks an email address depends on what the business sells and who is watching.
Three things make the ranking possible: a short list of assets, a way to score likelihood that reflects the attacker's cost rather than your unease, and a treatment for every row. The tracking plugin from the previous lessons gets a filled-in table at the end, along with a plain statement of what this kind of model does not find.
Five assets cover nearly everything a WordPress site protects
Rank by asset, not by finding type. A finding is a mechanism; an asset is the thing whose loss the business would have to explain. Most sites are protecting the same short list.
| Asset | What losing it costs | Who notices first |
|---|---|---|
| Customer personal data | Names, addresses, emails, order history and anything else that identifies a person. Loss brings notification duties, regulatory exposure and customer churn. | Customers, then a regulator |
| Order and payment integrity | Orders that were not paid for, refunds issued twice, prices altered, stock decremented wrongly. Loss is money and reconciliation work. | Finance, at month end |
| Administrator takeover | Control of the site and everything on it, including the ability to install code, read the database and take the customer list. | Nobody, until it is used |
| Availability | Requests that cost money, fill a queue, exhaust a worker pool or take checkout down. Loss is revenue per minute plus support load. | Customers, immediately |
| Reputation | Defacement, spam pages served under your domain, a search result that says your shop leaks data. Loss is trust, and it is slow to return. | Anyone who searches for you |
The useful property of this list is that it is short enough to argue about. When two findings compete for the same afternoon of work, the question becomes which asset is more exposed, and that is a question the business can answer. "Which finding is worse" is not.
Likelihood is the attacker's cost, not how alarming the finding sounds
Impact is the easy axis. Likelihood is where reviews go soft, because it is usually scored by how uncomfortable the finding made the reviewer feel. A better proxy is what the attack costs the attacker:
- Reachable with no account at all, and automatable across many sites in one script.
- Reachable by any registered account, including one created by the checkout flow.
- Reachable by an account with a specific staff role.
- Requires an administrator account, or a compromised one.
- Requires local access, a stolen secret, or is a theoretical consequence of another bug.
Two adjustments keep the score honest. Numbers that can be guessed or enumerated raise likelihood, which is why sequential order ids and short tracking numbers matter more than the endpoint they sit behind. Rate limits and monitoring lower likelihood, but only where they actually run: a limiter keyed on a client-supplied header lowers nothing.
The filled-in table for a tracking plugin
Here is the ranking for the fictional plugin from the previous two lessons: a REST lookup endpoint that returns shipment details for a tracking number, and a settings page that stores the carrier API key. Likelihood and impact are scored one to five. The score is the product, and the treatment is the decision, not the mechanism.
| # | Finding | Asset | L | I | Score | Treatment |
|---|---|---|---|---|---|---|
| F1 | Lookup returns another customer's shipment details for a guessed tracking number, including the destination city. | Personal data | 2 | 5 | 10 | Mitigate: check that the requester owns the order before returning details, and shorten the identifier space by using an unguessable reference. |
| F2 | Settings page saves the API key with a nonce but no capability check. | Admin takeover | 3 | 5 | 15 | Mitigate: require the capability that owns the option, and register the setting with a sanitise callback. |
| F3 | Lookup endpoint is anonymous and unrated, so each request costs a carrier API call. | Availability | 5 | 2 | 10 | Mitigate: cache by tracking number for a short window, then rate limit per connection with a proxy-supplied address. |
| F4 | Carrier response status is rendered unescaped on the order screen. | Admin takeover | 4 | 4 | 16 | Mitigate: escape at output, and treat the response as untrusted in the handler that decodes it. |
| F5 | Tracking number is submitted in the query string of an admin URL, so it lands in proxy and analytics logs. | Personal data | 4 | 3 | 12 | Mitigate: post the value, and redact query strings at the proxy for admin paths. |
| F6 | Carrier errors are written to a log file under the web root with the full tracking number and response body. | Personal data, reputation | 2 | 4 | 8 | Avoid: stop writing the response body, keep a fixed error code, and move logs outside the document root. |
| F7 | Deactivation leaves the API key in the options table forever. | Reputation | 3 | 2 | 6 | Accept for now, with a written note: the key is rotated on deactivation by the carrier's own console, and a cleanup routine is queued for the next release. |
Read the table as an argument rather than a verdict. F4 scores highest because a carrier response is attacker-influenced and an administrator's browser renders it, which reaches the most valuable asset with the least attacker effort. F2 sits one point below it and is easier to fix. F3 scores the same as F1 for completely different reasons, which is the first sign that a single number is a summary and not an answer.
Treatment has four outcomes, and accept is one of them
A finding is not finished when it is scored. It needs a decision, an owner and a date. Mitigation changes the code or the configuration so the exposure goes down. Avoidance removes the feature or the data that created the exposure, which is often cheaper than defending it. Transfer moves the risk to somebody else, through insurance or by making the third party whose API you call responsible for the class of failure they introduced. Acceptance records that you know, you are not fixing it now, and here is why.
Acceptance is the one teams skip, and skipping it is worse than accepting. An unrecorded acceptance is indistinguishable from an oversight three months later, so the finding returns in every review and consumes the same hour twice. A recorded acceptance has a reason and a review date, and it can be reopened when the reason stops being true.
A score makes the argument visible, and the script is all it is
Every scoring scheme in this lesson fits in twenty lines. That should lower your confidence in the number rather than raise it: the value is in forcing two people to say the likelihood and the impact out loud, not in the arithmetic.
<?php
function treatment( int $likelihood, int $impact ): string {
$score = $likelihood * $impact;
return match ( true ) {
$score >= 16 => 'fix before the next release',
$score >= 9 => 'fix this quarter',
$score >= 4 => 'schedule and monitor',
default => 'accept, and write down why',
};
}
$findings = [
[ 'lookup returns another customer email', 2, 5 ],
[ 'settings page saves without a capability check', 3, 4 ],
[ 'lookup endpoint open to anonymous traffic', 5, 2 ],
[ 'tracking code rendered unescaped in admin', 4, 4 ],
];
foreach ( $findings as [ $name, $likelihood, $impact ] ) {
printf(
"%-52s L%d I%d score %2d %s\n",
$name, $likelihood, $impact, $likelihood * $impact,
treatment( $likelihood, $impact )
);
}
Running that on PHP 8.5.6 prints this, and the output is the argument the scoring scheme makes: three of the four findings fall into one band, so the band cannot be the order in which they get fixed.
lookup returns another customer email L2 I5 score 10 fix this quarter
settings page saves without a capability check L3 I4 score 12 fix this quarter
lookup endpoint open to anonymous traffic L5 I2 score 10 fix this quarter
tracking code rendered unescaped in admin L4 I4 score 16 fix before the next release
Break ties with the asset rather than with the number. An availability finding and a personal data finding that both score ten are not equally urgent, because one of them stops when the attacker gets bored and the other one produces a notification letter. The score gets you into a band. The asset decides the order inside it.
A ninety-minute model finds known classes, not your business rules
Be clear about the limits of everything in this module, because someone will treat the output as coverage.
A threat model built in ninety minutes over a plugin's source finds known classes of problem. Missing capability checks. Unescaped output. Values interpolated into SQL. Uploads stored somewhere executable. Untrusted API responses. It finds these because they recur, and because a reviewer who knows the patterns can look for them quickly.
It will not find a logic flaw in your own business rules. If the lookup endpoint returns any shipment to anyone who knows a tracking number, that may be exactly what the feature was specified to do, and no amount of escaping, capability checking or prepared statements changes it. The same is true of a discount that stacks twice, a return that can be claimed on an order that was already refunded, or a subscription that can be moved to another account by an authenticated user who owns both. Those are failures of the rules, they are reachable with valid credentials and correct inputs, and the only way to find them is to read the rules against the code with somebody who knows what the business intends.
The model is also blind to what it was not told. It knows what the plugin does, not which numbers are revenue, which customers matter, or which outage would make the news. That information lives with the people who run the business, and it is the input that makes the ranking mean anything.
The deliverable is a table with owners and a review date
Finish the exercise by writing the model down. A findings table with the asset, the two scores, the treatment and a named owner is enough. Add the list of things you consciously accepted, because that list is what stops the same conversation from happening every quarter, and add a date on which the model gets revisited. The code you did not change is also a decision, and this is where you record it.
Take the boundary inventory and the attacker-control table you built in the previous two lessons and turn them into findings. Aim for six to ten rows. Each row needs a sentence that describes the exposure, the asset from the list in this lesson, a likelihood, an impact and a treatment.
Then sort the rows and answer three questions. Which finding do you fix first, and does the highest score agree with your answer? Which two rows score the same for different reasons? And which row are you accepting, with what reason written next to it?
Show the solution and the reasoning
The table in this lesson is a reasonable answer, and the reasoning behind three of its rows is what the lab is testing.
F4 usually wins on score and not always on priority. It scores sixteen because it puts attacker-influenced data into an administrator's browser. Whether it is fixed first depends on the likelihood input: a carrier status string that arrives over TLS from a vendor with a contract is harder to influence than a customer note, so a reviewer who scores it four is assuming something about the vendor that may not hold. If you cannot defend the four, score a three and check whether the ranking changes. It should not be a surprise when it does.
F1 and F3 are the tie the scoring cannot resolve. Both score ten. F1 exposes personal data to an attacker who guesses an identifier, and the damage is permanent and reportable. F3 exposes availability and the vendor bill, and the damage lasts as long as the flood. Personal data wins on almost every site, which is a decision about the business and not about the arithmetic. Write that sentence down next to the table, because it is the rule you will apply again next time.
F7 is the row most people leave blank. Accepting has a cost, which is that the key sits in an options row that an attacker with database read access can use, and it has a benefit, which is that a cleanup routine which deletes customer configuration on deactivation breaks more sites than it protects. The defensible answer records both, names who decided, and sets a review date. If your table has no row with an acceptance in it, you have either a plugin with no trade-offs or a review that stopped early.
What the table cannot contain is a finding whose mechanism is a rule rather than a bug. If the product decision is that anyone with a tracking number may see where a parcel is, the model records it as an accepted exposure and moves on, because there is no missing check to add. That is the boundary of this exercise, and knowing where it sits is the reason the next module starts with what validation and escaping can actually guarantee.
Progress
Mark this lesson done and it stays marked in this browser.