Skip to main content
DevSecOps (AppSec in the pipeline) · lesson 1 of 7

Threat Modeling as Code with pytm and STRIDE

Describe a login service as a pytm data-flow model, read the threats it generates, fix the public flow with TLS, and record why one finding is accepted.

By SkillByExample · Published

devsecops threat-modeling stride pytm python

A threat model answers “what could go wrong with this design?” before the code exists. OWASP pytm lets you write the design as a short Python file and get the answers back as data you can diff, review and re-run. Tested with pytm 1.4.0 on Python 3.12.15 (python:3.12-slim) and Docker Engine 29.8.2.

The lab

One image with pytm in it. Every example below is run from the folder that holds these files.

# Dockerfile
FROM python:3.12-slim
RUN pip install -q pytm==1.4.0
WORKDIR /w
$ docker build -q -t pytm-lab .
sha256:29b4705019149e4734569aec5cad6c9b7860888f01b5d77321f28d59e7eda87c

The model is a login service: a user on the internet sends credentials to an API, which looks the user up in a database. Three elements, two data flows and one trust boundary.

# model.py
from pytm import TM, Actor, Boundary, Dataflow, Datastore, Server

tm = TM("Login service")
internet = Boundary("Internet")

user = Actor("User")
user.inBoundary = internet
api = Server("API")
db = Datastore("UsersDB")

login = Dataflow(user, api, "Login request")
query = Dataflow(api, db, "Query user")

The model only declares things. Nothing happens until something calls tm.resolve().

Resolve the model

resolve() runs every rule in pytm’s library against every element and stores the matches in tm.findings.

# count.py
from collections import Counter
from model import tm

tm.resolve()
per_target = Counter(f.target for f in tm.findings)
for target, n in per_target.items():
    print(f"{target:15} {n:3} findings")
print(f"{'total':15} {len(tm.findings):3}")
$ docker run --rm -v "$PWD":/w pytm-lab python count.py
API              44 findings
UsersDB           4 findings
Login request     5 findings
Query user        5 findings
total            58

58 threats for a three-box diagram looks absurd, but it’s the honest starting point. Every control on every element defaults to False, so the model currently claims the API validates nothing, the flows encrypt nothing and the database has no access control. The API collects 44 because servers match the most rules: injection, XSS, session handling and so on. The User actor gets none, because the rules target things you build, not the people using them.

Read the findings on one flow

Start with the flow that crosses the internet boundary, since that’s where an attacker sits.

# flow.py
from model import tm

tm.resolve()
for f in tm.findings:
    if f.target == "Login request":
        print(f.threat_id, f.severity.ljust(6), f.description)
$ docker run --rm -v "$PWD":/w pytm-lab python flow.py
DE01 Medium Interception
AC05 Medium Content Spoofing
DE03 Medium Sniffing Attacks
CR06 High   Communication Channel Manipulation
CR08 Medium Client-Server Protocol Manipulation

Mapped to STRIDE, that’s information disclosure (interception, sniffing), tampering (content spoofing, channel manipulation) and spoofing of the server. Every one follows from a single fact the model states: the login request is plain, unauthenticated traffic. A password crossing the internet that way is the real design flaw, and the threat model found it before any code existed.

Fix the design, then re-resolve

Declare that the login flow uses HTTPS with TLS 1.3, that the client checks the server’s certificate and its revocation status, and that the API refuses anything below TLS 1.2.

# tls.py
from pytm import TLSVersion
from model import api, login, tm

def login_threats():
    tm.resolve()
    return {f.threat_id for f in tm.findings if f.target == "Login request"}

before = login_threats()
api.minTLSVersion = TLSVersion.TLSv12
login.protocol = "HTTPS"
login.tlsVersion = TLSVersion.TLSv13
login.controls.isEncrypted = True
login.controls.authenticatesDestination = True
login.controls.checksDestinationRevocation = True
after = login_threats()

print("fixed:", sorted(before - after))
print("left: ", sorted(after))
print("total findings now:", len(tm.findings))
$ docker run --rm -v "$PWD":/w pytm-lab python tls.py
fixed: ['AC05', 'CR08', 'DE01']
left:  ['CR06', 'DE03']
total findings now: 55

Three findings closed, and the total dropped from 58 to 55, which also shows that resolve() starts from scratch each time rather than appending. But two are still open, even though the flow is now encrypted. TLS alone didn’t satisfy those rules, so the next step is to read them.

Read the rule, not just the title

pytm’s rules are plain JSON in the installed package. Each one has a condition: a Python expression evaluated against the element.

# why.py
import json
from pathlib import Path
import pytm

rules = Path(pytm.__file__).parent / "threatlib" / "threats.json"
for threat in json.loads(rules.read_text()):
    if threat["SID"] in ("CR06", "DE03"):
        print(threat["SID"], threat["condition"])
$ docker run --rm -v "$PWD":/w pytm-lab python why.py
DE03 (target.protocol == 'HTTP' or target.controls.isEncrypted is False) or target.usesVPN is False
CR06 (target.protocol != 'HTTPS' or target.usesVPN is False) and (target.controls.implementsAuthenticationScheme is False or target.controls.authorizesSource is False)

The conditions explain both leftovers. CR06 also wants the flow to authenticate and authorise its source, which a login endpoint does once the user is signed in. DE03 ends in or target.usesVPN is False, so it fires on any flow that isn’t inside a VPN, however well encrypted. A public login page can’t put its users on a VPN, so no honest control will close that one.

Accept what you won’t fix, in writing

Declare the real authentication controls for CR06. For DE03, add an override: the finding stays, but it now carries the reason the team accepted it.

# accept.py
from pytm import Finding
from model import login, tm
import tls  # noqa: F401  (applies the TLS fix and prints its two lines)

login.controls.implementsAuthenticationScheme = True
login.controls.authorizesSource = True
login.overrides = [
    Finding(threat_id="DE03",
            response="Accepted: TLS 1.3 on the public flow; no VPN for end users."),
]
tm.resolve()
for f in tm.findings:
    if f.target == "Login request":
        print(f.threat_id, "->", f.response or "(open)")
$ docker run --rm -v "$PWD":/w pytm-lab python accept.py
fixed: ['AC05', 'CR08', 'DE01']
left:  ['CR06', 'DE03']
total findings now: 55
DE03 -> Accepted: TLS 1.3 on the public flow; no VPN for end users.

The first three lines come from importing tls.py, which applies the TLS fix and prints its own output. After that, CR06 is gone, and DE03 is the only finding left on the flow, with a written decision instead of (open). That’s the point of threat modeling as code: the model, the controls and the accepted risks all live in one reviewed file, so a later change that sets isEncrypted back to False reopens the findings in the next pull request.

Practice

  1. Run flow.py again, filtered on the Query user flow instead. Which findings does it share with the login flow, and why?

    Solution

    Change the filter to f.target == "Query user". It reports the same five IDs (DE01, AC05, DE03, CR06, CR08), because pytm’s flow rules look at the flow’s own controls. An internal flow with no encryption declared is treated exactly like a public one, which is why “it’s inside our network” has to be stated in the model, or fixed with TLS, rather than assumed.

  2. In tls.py, set login.tlsVersion = TLSVersion.TLSv11 instead of TLS 1.3. Predict which finding comes back, then run it.

    Solution

    DE01 and CR08 both compare target.tlsVersion < target.sink.minTLSVersion. With the API requiring TLS 1.2 and the flow at 1.1, both fire again, so fixed shrinks to ['AC05']. The model catches a downgrade even though isEncrypted is still True.

  3. Print the 44 API findings grouped by severity using Counter(f.severity ...). Then set api.controls.validatesInput = True and api.controls.sanitizesInput = True and compare.

    Solution
    from collections import Counter
    from model import api, tm
    
    tm.resolve()
    print(Counter(f.severity for f in tm.findings if f.target == "API"))
    api.controls.validatesInput = True
    api.controls.sanitizesInput = True
    tm.resolve()
    print(Counter(f.severity for f in tm.findings if f.target == "API"))

    The API’s findings fall from 44 to 35: Very High 5 to 3, High 16 to 12, Medium 19 to 16, because many injection rules depend on input validation. Declare a control only if the code really implements it.

  4. Add a second Boundary("Company network"), put api and db inside it, and re-run count.py. Does the total change?

    Solution

    Set api.inBoundary = company and db.inBoundary = company in model.py. The total stays at 58, and the login flow keeps all five findings, because those rules depend on the flow’s controls, not on where its ends sit. Boundaries document trust and show up on the diagram; they don’t encrypt anything.

Where this fits

Threat modeling is design-time: it decides which controls you need. The rest of this topic checks that the code and the pipeline actually have them, starting with static analysis.

Frequently Asked Questions

What is threat modeling as code?
It means describing your system's components and data flows in a programming language instead of drawing them in a GUI tool. A library such as OWASP pytm then evaluates rules against that description and lists the threats that apply. Because the model is a file, it lives in the repository, gets reviewed in pull requests and can be re-run whenever the design changes.
Does pytm find vulnerabilities in my code?
No. pytm never reads your application code. It evaluates the facts you declare about each element, such as whether a flow is encrypted or a server validates input, against a rule library. If you declare something untrue, the findings are wrong. Its job is to make design assumptions explicit, not to scan an implementation.
What does STRIDE stand for?
Spoofing, Tampering, Repudiation, Information disclosure, Denial of service and Elevation of privilege. It is a checklist of threat categories from Microsoft, applied to every element of a data-flow diagram. pytm's rule library covers the same ground, with each rule mapped to CAPEC and CWE references.
Why does pytm report so many findings for a tiny model?
Every control on an element defaults to False, so a fresh model claims nothing is protected and most rules fire. The count falls as you declare the controls you really have. Treat the first run as a list of questions about the design, not as a list of bugs.
How do I mark a pytm finding as accepted?
Add a Finding with that threat ID and a response string to the element's overrides list. The finding still appears, but it now carries your written reason, so the report shows a decision instead of an open item. That is the right way to handle a rule that does not fit your design.