DevSecOps (AppSec in the pipeline) · lesson 1 of 7
Beginner
3Intermediate
3Also part of DevSecOps (AppSec in the pipeline)
10- + Bash Security Best Practices from Bash
- + Secrets: Real Log Masking, and the Base64 Trick That Defeats It from GitHub Actions
- + Docker Security from Docker
- + Multi-Stage Builds from Docker
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything from Ansible
- + Scanning Terraform for Misconfiguration with Checkov from Cloud Security
- + Policy as Code: Testing Terraform Plans with OPA and Conftest from Cloud Security
- + MCP Authorization: OAuth 2.1, CIMD and a Real Identity Provider from MCP (Model Context Protocol)
- + Running MCP in Production: Scaling, Caching and Operations from MCP (Model Context Protocol)
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
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
-
Run
flow.pyagain, filtered on theQuery userflow 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. -
In
tls.py, setlogin.tlsVersion = TLSVersion.TLSv11instead 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, sofixedshrinks to['AC05']. The model catches a downgrade even thoughisEncryptedis stillTrue. -
Print the 44 API findings grouped by severity using
Counter(f.severity ...). Then setapi.controls.validatesInput = Trueandapi.controls.sanitizesInput = Trueand 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.
-
Add a second
Boundary("Company network"), putapianddbinside it, and re-runcount.py. Does the total change?Solution
Set
api.inBoundary = companyanddb.inBoundary = companyinmodel.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.