Worked Case: Threat Model and SAST Triage for a Web Shop
This worked case takes one small web application from source code to a release decision. The methods are on Threat Modeling, CVSS Scoring, From Severity to Fix Order and Static Security Analysis.
Example: the shop before its first release. A Flask shop (constructed example): app/web.py holds the routes, app/shop.py is the module from Static Security Analysis, app/reports.py counts orders, and tests/test_shop.py has three pytest checks. These are the exact files Bandit scanned in §3, so its line numbers match. app/web.py:
import random
import sqlite3
import string
import requests
from flask import Flask, abort, request, session
from app import shop
app = Flask(__name__)
app.secret_key = shop.SECRET_KEY
PAYMENT_URL = "https://pay.example.com/v1/charges"
def db():
return sqlite3.connect("shop.db")
def audit(event):
with open("audit.log", "a") as log:
log.write(event + "\n")
@app.post("/login")
def login():
user = shop.find_user(db(), request.form["name"], request.form["password"])
if user is None:
abort(401)
session["user_id"] = user[0]
return "ok"
@app.post("/checkout")
def checkout():
if "user_id" not in session:
abort(401)
price = shop.apply_discount(float(request.form["price"]), request.form["code"])
charge = requests.post(PAYMENT_URL, json={"amount": price})
audit(f"order user={session['user_id']} amount={price}")
return charge.json()
@app.post("/admin/refund/<int:order_id>")
def refund(order_id):
if "user_id" not in session:
abort(401)
db().execute("UPDATE orders SET refunded = 1 WHERE id = ?", (order_id,))
audit(f"refund order={order_id} by={session['user_id']}")
return "refunded"
def order_reference():
return "".join(random.choice(string.ascii_uppercase) for _ in range(8))
if __name__ == "__main__":
app.run(host="0.0.0.0", debug=True)
app/shop.py:
import hashlib
import sqlite3
SECRET_KEY = "shop-admin-2024"
def hash_password(password):
return hashlib.md5(password.encode()).hexdigest()
def find_user(db, name, password):
query = f"SELECT id FROM users WHERE name = '{name}' AND pw = '{hash_password(password)}'"
return db.execute(query).fetchone()
def apply_discount(price, rule):
return eval(rule, {"price": price})
app/reports.py, and tests/test_shop.py, whose assert lines are 5, 9 and 13:
ORDERS_TABLE = "orders"
def order_count(db):
return db.execute(f"SELECT COUNT(*) FROM {ORDERS_TABLE}").fetchone()[0]
from app import shop
def test_discount_none():
assert shop.apply_discount(10.0, "price") == 10.0
def test_discount_student():
assert shop.apply_discount(10.0, "price * 0.9") == 9.0
def test_hash_is_stable():
assert shop.hash_password("a") == shop.hash_password("a")
Customers pay on the payment provider’s hosted page, so card numbers never reach the shop. (Bandit 1.9.4 on Python 3.12.9 was run for this page; CVSS 3.1 scores were computed and checked against CVSS Scoring.)
1. Draw the system
On a real system the diagram comes from three sources, not from memory: the route list (flask routes prints every endpoint), the deployment configuration (what runs where, which ports are open), and the places the code reads and writes (database URLs, files, outgoing HTTP calls; grep -rn "requests\.\|open(\|connect(" app finds them). Number every element, as Shostack recommends [1], so that each threat can name its element.
The shop’s DFD with numbered elements and the threats found on each (letters are STRIDE). [1]
Decision point: what is in scope? Criterion: every element that holds or moves data whose loss would harm customers or the business, and every flow that crosses a trust boundary. The provider’s internals are out of scope; the flow to it, F5, is in.
2. Walk STRIDE per element and score each threat
Apply the letters each element type is open to (external entities S, R; processes all six; flows and stores T, I, D, and a store that is a log also R, because it must prove who did what [1]) and ask one question per letter: “how could someone do this here?” Record S on the entity whose identity is faked, following Threat Modeling. The walk’s coverage, with “–” for a letter considered where nothing was found and “·” for a letter that does not apply:
| Element | S | T | R | I | D | E |
|---|---|---|---|---|---|---|
| E1 Customer | T1, T5, T6 | · | – | · | · | · |
| E2 Admin | T5 | · | – | · | · | · |
| E3 Payment provider | – | · | – | · | · | · |
| P1 Web shop | – | T4 | – | – | – | T2, T3 |
| F1, F2 (cross the boundary; TLS at the proxy) | · | – | · | – | – | · |
| F3, F4 (inside the server) | · | – | · | – | – | · |
| F5 Charge | · | – | · | T9 | T10 | · |
| DS1 Shop database | · | T1 | · | T1 | T8 | · |
| DS2 Audit log | · | T7 | T7 | – | – | · |
Set the environmental requirements once, for the whole shop: CR:M (names, addresses and order history, no card data), IR:H (orders and refunds move money), AR:M (an outage loses sales, not safety). Then score each threat with procedure (b) on CVSS Scoring, the rule for a team’s own findings: scope stays unchanged unless the impact lands in a component under another security authority. Every impact here stays inside the shop’s own authority, so all are S:U. The environmental score uses the formulas on From Severity to Fix Order. “CVSS Low, Medium, High” are rating bands; “Bandit severity” is the tool’s own scale.
Decision rule (fixed before scoring): an open threat with environmental score 7.0 or higher blocks the release. A CVSS Medium (4.0–6.9) is mitigated by a named date or accepted by the product owner with an expiry date and a compensating control. A CVSS Low may be accepted with an owner. A threat whose handling belongs to another party by contract is transferred, and the contract is checked.
This is the deliverable; copy the columns:
| ID | Element | STRIDE | Threat | CVSS 3.1 base vector | Base | Env | Owner | Decision |
|---|---|---|---|---|---|---|---|---|
| T1 | DS1; E1 | I, T; S | SQL injection in login reads and changes any account; the attacker acts as its owner | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N | 9.1 | 9.8 | Backend | Mitigate: parameterised query. Blocks |
| T2 | P1 | E | Discount code reaches eval: any customer runs code in the shop | AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H | 8.8 | 8.8 | Backend | Mitigate: allowlist. Blocks |
| T3 | P1 | E | Refund route checks login, not admin role: any customer refunds any order | AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N | 6.5 | 8.3 | Backend | Mitigate: role check, 403. Blocks |
| T5 | E1, E2 | S | Session key in source: whoever reads the repository forges a session for any user, admin included | AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N | 7.4 | 8.1 | Ops | Mitigate: key from environment, rotate. Blocks |
| T6 | E1 | S | No rate limit on login: reused passwords from other breaches open accounts | AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N | 6.5 | 7.0 | Ops | Mitigate: rate limit at the proxy. Blocks |
| T4 | P1 | T | Price taken from the request: a customer pays what they type | AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N | 4.3 | 5.0 | Backend | Mitigate now: server-side price list (one line) |
| T10 | F5 | D | Payment call has no timeout: while the provider is slow, a customer starting many checkouts ties up every worker | AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H | 5.3 | 5.3 | Backend | Mitigate by 2026-12-01: timeout=10; Bandit baseline until then |
| T8 | DS1 | D | Scripted fake orders fill the database | AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L | 4.3 | 4.3 | Product owner | Accept until 2026-12-01; alert on order rate |
| T7 | DS2 | T, R | An admin with a shell edits the audit log to hide a refund | AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:L/A:N | 2.3 | 3.0 | Ops | Accept until 2026-12-01; then ship the log off the server |
| T9 | F5 | I | Card data exposed in the payment flow | not scored | – | – | Product owner | Transfer: hosted payment page, provider’s compliance |
T5 is AC:H because the attacker must first obtain the source code, and T10 because it needs the provider to be slow; both are conditions outside the attacker’s control. The dates are constructed. Two rows show why the environmental score matters. T3 is Medium on its base score (6.5) but High (8.3) once integrity is weighted for money; T6 lands exactly on the gate at 7.0. Recompute one row to check: for T3, the modified impact sub-score MISS = 1 − (1 − 1.5 × 0.56) = 0.84, Modified Impact = 6.42 × 0.84 = 5.393, plus exploitability 8.22 × 0.85 × 0.77 × 0.62 × 0.85 = 2.835, gives roundup(8.228) = 8.3, where roundup rounds up to one decimal.
3. Run the static analyser
The input is the source tree as it will ship. Bandit over everything, one line per finding:
$ bandit -q -r app tests -f custom --msg-template '{relpath}:{line} {test_id} {severity} {confidence}'
app/reports.py:5 B608 MEDIUM MEDIUM
app/shop.py:4 B105 LOW MEDIUM
app/shop.py:8 B324 HIGH HIGH
app/shop.py:12 B608 MEDIUM LOW
app/shop.py:17 B307 MEDIUM HIGH
app/web.py:38 B113 MEDIUM LOW
app/web.py:53 B311 LOW HIGH
app/web.py:57 B201 HIGH MEDIUM
app/web.py:57 B104 MEDIUM MEDIUM
tests/test_shop.py:5 B101 LOW HIGH
tests/test_shop.py:9 B101 LOW HIGH
tests/test_shop.py:13 B101 LOW HIGH
4. Triage every finding
Decision point: what is each finding? Criterion: one of four verdicts per finding. Real: it maps to a threat row, or opens a new one. Hardening: a weakness with no reachable attack today; fix when cheap. False positive: the pattern is there, the danger is not; suppress with a reason. Deferred: real, mapped to a threat row below the gate; record it in the baseline until that row’s date.
| Finding | Verdict | Action |
|---|---|---|
shop.py:12 B608, shop.py:17 B307 | Real: T1, T2 | Fix (blocks) |
shop.py:4 B105 | Real: T5. Bandit severity Low; the key signs sessions, so it is CVSS 8.1 (High) | Fix (blocks) |
shop.py:8 B324 MD5 | Real (part of T1) | Fixed with T1 (salted slow hash) |
web.py:57 B201, B104 | Hardening: the debugger allows code execution, but this block runs only under python app/web.py, never when gunicorn imports the module in production | app.run() with defaults |
reports.py:5 B608 | False positive: constant table name | # nosec B608 |
web.py:53 B311 | False positive: an order number, not a secret | # nosec B311 |
web.py:38 B113 | Deferred: T10 (CVSS 5.3) | Baseline until 2026-12-01 |
tests/* B101 | False positive: assert is how pytest checks | exclude_dirs = ["tests"] under [tool.bandit] in pyproject.toml |
Bandit found the code-level half of the threat table, T1, T2 and T5, plus one weakness the walk had missed, B113, which opened T10. It found nothing for T3, T4, T6, T7, T8 or T9, because each is a missing check or a design decision, not a dangerous line: McGraw’s bugs and flaws [2].
The # nosec policy. A suppression names its test ID, so it hides one rule, not the whole line, and its reason goes on the line above. Putting the reason after the ID does not work: Bandit reads every following word as a test name and prints a warning per word, such as Test in comment: constant is not a test name or id, ignoring. So:
def order_count(db):
# safe because: table name is a module constant; no input reaches the query
return db.execute(f"SELECT COUNT(*) FROM {ORDERS_TABLE}").fetchone()[0] # nosec B608
The baseline. After the fixes, the remaining deferred finding is frozen into a baseline, and the build compares against it, so only new findings fail the build [3]:
$ bandit -q -c pyproject.toml -r . -f json -o .bandit-baseline.json
$ bandit -q -c pyproject.toml -r . -b .bandit-baseline.json; echo "exit=$?"
exit=0
To check that the gate still bites, a new function with subprocess.run(f"... > {path}", shell=True) was added: the same command then reports B404 (Bandit severity Low) and B602 (High) and exits 1, while the baselined B113 stays silent.
5. Decide
The release is blocked until T1, T2, T3, T5 and T6 are mitigated, since each scores 7.0 or more. It ships when three checks pass. White-box exploit tests for the three blockers fixed in code, T1, T2 and T3, must pass; they are written as on Dynamic Security Testing. T5 and T6 are fixed in configuration (a rotated key, a proxy rule), so they are checked at deployment instead. Bandit against the baseline must exit 0. And the threat table must have no open row at 7.0 or above. T4 and the hardening fixes go in the same release because they are cheap. T10 is mitigated by its date. T7 and T8 are accepted, with an owner and an expiry. T9 is transferred.
The pipeline gate, added to the workflow on DevSecOps and Supply Chain Security:
- run: pip install bandit
- run: bandit -c pyproject.toml -r . -b .bandit-baseline.json
- run: | # every nosec names its test ID
! grep -rnE '#\s*nosec(\s*$|[^ ]|\s+[^B])' app
The last step fails on a bare # nosec, on #nosec and on # nosec because …; tested, it prints the offending line and exits 1.
Common mistakes
- Reading Bandit’s severity as risk. The hard-coded key had Bandit severity Low and was the fourth-highest threat. Spot it: a release decision phrased in Bandit severities.
- A clean scan read as a secure app. After the fixes Bandit is silent, yet without the walk T3 and T6 would still be open. Spot it: a threat table with no rows that lack a tool finding.
- A baseline used as an amnesty. Spot it: High findings or entries without a ticket in
.bandit-baseline.json. Keep High findings out of it. - Reasons after
# nosec. Spot it: “Test in comment” warnings in the scan log.
How solid is this?
- Where it comes from. The four questions, numbered DFD elements and STRIDE-per-element from Shostack [1]; CVSS 3.1 [4]; Bandit’s documentation and this run [3]. The decision rule is this page’s choice, not a standard.
- What is contested. Completeness: students using STRIDE found 36% of the known threats, with 81% of what they reported being correct [5], so a filled table is a floor. Real projects took about 69 hours [6]; this shop is far smaller.
References
- A. Shostack,Threat Modeling: Designing for Security. Wiley, 2014.
- G. McGraw,Software Security: Building Security In. Addison-Wesley, 2006.
- PyCQA,“Bandit: a tool designed to find common security issues in Python code.” 2025. [Online]. Available: https://bandit.readthedocs.io/
- Forum of Incident Response and Security Teams,“Common Vulnerability Scoring System v3.1: Specification Document.” 2019. [Online]. Available: https://www.first.org/cvss/specification-document
- R. Scandariato, K. Wuyts, and W. Joosen,“A Descriptive Study of Microsoft’s Threat Modeling Technique,” Requirements Engineering, vol. 20, no. 2, pp. 163–180, 2015, doi: 10.1007/s00766-013-0195-2.
- K. Yskout, T. Heyman, D. Van Landuyt, L. Sion, K. Wuyts, and W. Joosen,“Threat Modeling: From Infancy to Maturity,” in ICSE-NIER 2020, 2020, pp. 9–12. doi: 10.1145/3377816.3381741.
Disclaimer: AI is used for text summarization, polishing and explaining. Authors have verified all facts and claims. In case of an error, feel free to file an issue.