Threat Modeling as Code: Running Threagile on Every Architecture Change
Most of what comes out of a STRIDE session isn’t novel. It’s the same handful of patterns recurring across every architecture: a data flow crosses a trust boundary without encryption, an admin interface is reachable from a zone it shouldn’t be, two services talk to each other with no authentication because “they’re both internal.” Catching those is valuable — but it’s also mechanical. It doesn’t need a human in the room. What needs a human in the room is “is this acceptable risk for us,” and that conversation gets shorter, not longer, when the mechanical findings are already on the table before the session starts.
That’s the gap Threagile fills. It’s “threat modeling as code”: you describe your architecture, assets, and trust boundaries in a single threagile.yaml, a rule engine runs against that model, and out comes a STRIDE-categorized risk report, an auto-generated data-flow diagram, and machine-readable exports. Because the model is plain text, it lives in the repo next to the architecture it describes — versioned, diffed in pull requests, and rerun automatically whenever that architecture changes.
The lab’s git forge is self-hosted Forgejo (covered in the SSO walkthrough), so wiring this in meant working out the Forgejo Actions path specifically — which turned out to be a useful lens on how differently GitHub, GitLab, and Forgejo each handle the same Docker-shaped problem.
The model, locally first
Before any of it touches CI, it has to run on a laptop. Threagile ships as a Docker image, so there’s no Go toolchain to install:
# Generate a starting skeleton — copy and adapt, don't write from scratch
docker run --rm -it -v "$(pwd)":/app/work threagile/threagile \
--create-example-model --output /app/work
# Run the analysis against the model
docker run --rm -it -v "$(pwd)":/app/work threagile/threagile \
--verbose --model /app/work/threagile.yaml --output /app/work
That produces report.pdf (the categorized findings with severities and suggested mitigations), data-flow-diagram.png, and a set of JSON/Excel exports for further tooling. The thing worth internalizing here is where threagile.yaml lives: next to the system it describes, not off in a separate “security” directory that nobody opens unless they’re already looking for it. A threat model that isn’t sitting in the same diff as the architecture change that prompted it might as well not exist — nobody re-reads it, and it drifts.
Wiring it into Forgejo Actions
Forgejo Actions runs on act_runner and speaks a compatible subset of GitHub Actions syntax — but self-hosted runners typically can’t (and shouldn’t be configured to) reach the GitHub Marketplace. So instead of depending on the official threagile/run-threagile-action, the move is the same one that works for GitLab: run the Docker image directly.
.forgejo/workflows/threagile.yaml:
on:
push:
paths:
- 'threagile.yaml' # only fire when the model itself changes
jobs:
threagile_job:
runs-on: docker
container:
image: threagile/threagile
options: --entrypoint=""
steps:
- name: Checkout Workspace
uses: actions/checkout@v4
- name: Run Threagile
run: |
mkdir -p threagile-output
/app/threagile --verbose --model "$GITHUB_WORKSPACE/threagile.yaml" --output "$GITHUB_WORKSPACE/threagile-output"
- name: Archive Results
uses: actions/upload-artifact@v4
with:
name: threagile-report
path: threagile-output/
Two details cost more time than they should have:
The entrypoint override. The threagile/threagile image’s default entrypoint is the Threagile binary — which means if you just point a job’s container: at it, the binary swallows the job before your steps: ever run. options: --entrypoint="" clears that, so the runner can execute the workflow’s actual commands inside the image. GitLab needs the equivalent (entrypoint: [""] in its image: block) for the same reason — it’s the same problem wearing two different YAML keys.
Path filtering, deliberately narrow. paths: ['threagile.yaml'] keeps this job from firing on every commit. That’s not laziness — it’s what keeps “a new report was generated” meaningful as a review signal. If the job runs on every push regardless of whether the model changed, the report becomes wallpaper, and wallpaper gets ignored. The trigger should mirror the same “re-run on significant change” judgment a STRIDE session uses to decide whether last quarter’s model still applies.
Where the walkthrough almost shipped wrong
Writing up the reference workflow side by side with GitHub’s version surfaced three things that would have been easy to copy-paste straight into a real pipeline and regret later — which is exactly the kind of thing a second pass over your own documentation is for.
Stale action versions. The first draft of the GitHub example pinned actions/checkout@v2 and actions/upload-artifact@v2. v2 of upload-artifact is deprecated — GitHub has been actively shutting down runs that still use it. The Forgejo example already used @v4 for both; the GitHub one should have matched from the start. If a walkthrough is going to get copy-pasted into someone’s actual pipeline (including future-me’s), it shouldn’t hand them a build that’s scheduled to break.
The commit-back step had no guard. Some teams like to push the rendered report and DFD back into the repo so they’re browsable from the README without anyone having to open a CI run. The reference version of that step was:
git add threagile/output/report.pdf
git add threagile/output/data-flow-diagram.png
git commit -m "Update threat model report and data-flow diagram by Threagile"
git push
That fails the job — hard — on any run where Threagile’s output is byte-identical to what’s already committed, because git commit exits non-zero when there’s nothing staged to commit. A model change that doesn’t alter the rendered report (a comment, a metadata tweak) would turn a passing pipeline red for no actionable reason. The fix is to make “nothing changed” a clean no-op:
git diff --cached --quiet && echo "No report changes to commit" || \
(git commit -m "Update threat model report and data-flow diagram by Threagile" && git push)
Small thing. The kind of small thing that pages someone at a bad hour the first time it actually happens.
No mention of token scope. Pushing back to the repo from a workflow needs contents: write. The original walkthrough just… didn’t say that, which means the natural failure mode is someone reaching for the broadest token scope available rather than the narrowest one that works — the opposite of what you’d want from anything touching CI credentials. permissions: contents: write, scoped to the job or the step, stated explicitly rather than inherited from whatever the org’s default happens to be this month. Least privilege isn’t a principle that stops applying once the thing doing the pushing is a robot instead of a person.
None of these are exotic findings. They’re the same category of thing Threagile itself is built to catch in an architecture: small, mechanical, easy to miss on a first pass, and only expensive once something depends on them being right.
It would be easy to wave all of this off as overkill for a lab. The attack surface here is a fraction of what I deal with professionally, and nobody’s losing sleep over the threat model for a homelab Forgejo instance. But the lab isn’t only where I keep my own edge sharp — it’s also where my son is learning what this work actually looks like. Branches, pipelines, least-privilege tokens, the whole loop from “someone changed the architecture” to “the build caught it before a human had to.” That’s not something most high schoolers get within a mile of. If the workflows he’s watching get built sloppy because “it’s just a lab,” that’s the lesson that sticks — and it’s the wrong one. Doing it properly here is doing it properly where it actually teaches something.
What this actually buys
Once the workflow is in place, the loop looks like this: someone changes the architecture, the model YAML changes alongside it in the same PR, Threagile runs, and the categorized findings — and the diagram — are sitting in the build output before anyone opens a STRIDE session. risks.json gets piped into the same findings backlog that manually-discovered risks go through, so a Threagile finding gets the same lifecycle treatment as one a human caught — triaged, owned, closed, not left to rot in a PDF inside a build artifact nobody downloads.
That’s the trade this is actually making. The rule engine doesn’t replace the judgment call about whether a given risk is acceptable for this system, this team, this threat model. It just makes sure that by the time humans sit down to make that call, they’re not spending the first twenty minutes rediscovering the same missing-encryption finding the engine would have flagged on commit. The session gets to start at the interesting question instead of ending there.