Markdown Templates for Agile Threat Modeling
Storing threat models as version-controlled Markdown eliminates the drift that kills static diagrams: the model lives in the same repository as the code it describes, gets reviewed in the same pull request, and is validated by the same CI pipeline that gates deployment. Without this approach, threat documentation becomes a compliance checkbox that no one trusts after the first sprint.
This guide is part of Threat Model Documentation Patterns, which sits within the broader Threat Modeling Fundamentals & Methodology discipline. The techniques here apply STRIDE framework classification at the field level so that automated tooling can enforce coverage without human review bottlenecks.
Prerequisites
- Git repository with branch protection enabled on
main - Python 3.11+ or Node 20+ available in CI runners
pyyaml(Python) orjs-yaml(Node) installable in the pipeline- Familiarity with YAML frontmatter and GitHub Actions (or equivalent CI)
- Working knowledge of STRIDE categories and basic risk scoring
Expected Outcomes
- A repeatable Markdown file schema that every service team can adopt in one sprint
- A GitHub Actions workflow that rejects non-conforming threat models at merge time
- A Python export script that generates SOC 2 and ISO 27001 evidence artifacts from Markdown source
- CI behavior validated against real threat model files with known pass and fail cases
Step 1: Define the Markdown Schema — Frontmatter and Threat Table
A lightweight, machine-readable schema gives consistency across distributed teams while remaining editable in any IDE. The schema has two parts: a YAML frontmatter block at the top of each file and a standardized threat table in the body.
YAML Frontmatter
Define explicit asset classification, ownership, and compliance mappings at the top of each Markdown file. The CI pipeline parses this block first — missing required fields trigger immediate build failures, so the schema acts as a forcing function for completeness.
---
title: "Payment Gateway Service"
service_id: "svc-payment-gateway"
owner: "@team-backend-payments"
classification: "PCI-DSS-High"
trust_boundaries:
- "Public Internet -> WAF"
- "WAF -> API Gateway"
- "API Gateway -> Internal VPC"
compliance_controls:
- "SOC2-CC6.1"
- "ISO27001-A.12.6.1"
- "PCI-DSS-Req-1.3"
last_review: "2026-05-15"
status: "active"
---
service_id must be kebab-case and globally unique across the monorepo — the export script uses it as the output filename. trust_boundaries must enumerate every network segment boundary that data crosses; implicit boundaries are rejected at lint time. Align these declarations with mapping trust boundaries in cloud-native apps to ensure the Markdown schema and your network topology stay synchronized.
Threat Table Format
Use a strict Markdown table for threat enumeration. Every row must contain a unique threat ID, STRIDE classification, quantified risk score, and explicit mitigation status. The columns cannot be reordered — the parser relies on positional column indices.
| Threat ID | Vector | STRIDE | Impact (1-5) | Likelihood (1-5) | Risk Score | Mitigation | Status | Owner |
|---|---|---|---|---|---|---|---|---|
| T-001 | JWT Token Replay | Tampering | 5 | 3 | 15 | Implement jti claim validation + Redis token blacklist |
Implemented | @sec-eng |
| T-002 | SSRF via Webhook URL | Spoofing | 4 | 2 | 8 | Enforce strict allowlist + egress proxy | Pending | @dev-lead |
Risk Score equals Impact × Likelihood. The CI gate blocks merges when a row has Risk Score ≥ 15 and Status is not Implemented or Accepted. Rows with Accepted status require an additional risk_owner field and an acceptance_expiry date in the frontmatter.
Version Control and Branching
- One Markdown file per deployable service or bounded context — never combine services in a single file.
- Branch naming:
sec/threat-model/<service-name>for initial creation;sec/update/<service-name>for iterative reviews. - Direct commits to
mainare blocked for files underthreat-models/**. All changes require approval from both a security engineer and the designated service owner before merge.
Step 2: Wire the CI/CD Validation Pipeline
Automated validation prevents architectural drift during rapid deployments. The pipeline runs three sequential checks before allowing a merge: schema linting, STRIDE tag enforcement, and a risk score gate.
Pre-Merge Schema Linting
Deploy a validation job that parses YAML frontmatter and verifies table structure. Use ruamel.yaml or js-yaml for strict schema validation. Reject files containing unescaped pipe characters in table cells or missing trust_boundaries arrays.
Automated STRIDE Tagging
Enforce STRIDE compliance via regex. Every threat entry must map to exactly one category. The pipeline flags entries with invalid tags or missing impact/likelihood scores.
import re
VALID_STRIDE = re.compile(
r"^(Spoofing|Tampering|Repudiation|Information Disclosure"
r"|Denial of Service|Elevation of Privilege)$"
)
def validate_stride_tags(rows: list[dict]) -> list[str]:
errors = []
for row in rows:
if not VALID_STRIDE.match(row.get("stride", "")):
errors.append(f"Row {row['threat_id']}: invalid STRIDE tag '{row.get('stride')}'")
return errors
PR Gate Enforcement
Block merges if high-risk threats (Risk Score ≥ 15) lack an Implemented or Accepted status. Risk acceptance requires explicit risk_owner assignment and a documented expiration date in frontmatter.
# .github/workflows/threat-model-validation.yml
name: Threat Model Validation
on:
pull_request:
paths:
- 'threat-models/**/*.md'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Dependencies
run: pip install pyyaml markdown-it-py
- name: Lint & Validate
run: |
python scripts/validate_threat_model.py \
--path threat-models/ \
--max-risk-threshold 14 \
--require-stride-tag \
--enforce-trust-boundaries
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
The validate_threat_model.py script exits with code 1 on any violation, which causes the Actions job to fail and blocks the merge. High-risk threats with status Pending produce a hard failure; medium-risk threats (Risk Score 8–14 with status Pending) produce a warning annotation that does not block merge but is visible in the PR review.
Step 3: Handle Dynamic Cloud Assets and Ephemeral Environments
Static schemas fail in cloud-native architectures where assets scale, rotate, or deploy across ephemeral environments. Address this with parameterized placeholders and runtime discovery hooks.
Ephemeral Container and Serverless Assets
Replace hardcoded IPs and ARNs with environment variables resolved at deploy time. Use IaC outputs to inject runtime asset lists into the Markdown template during the build phase.
# Dynamic frontmatter injection via CI — values resolved from Terraform outputs
runtime_assets:
- "${AWS_LAMBDA_FUNCTION_ARN}"
- "${ECS_SERVICE_CLUSTER}"
- "${K8S_NAMESPACE}/svc-${ENVIRONMENT}"
A pre-deploy CI step runs envsubst over the Markdown file before validation, substituting variables from the IaC state output. The substituted file is validated, then the original (with placeholders) is what gets committed — preserving portability across environments.
Dynamic Trust Boundary Mapping
Define boundaries as logical network segments rather than static CIDRs. Integrate with cloud provider APIs to validate that declared boundaries match deployed network policies. Flag mismatches as critical CI failures.
# scripts/validate_boundaries.py
import boto3
import yaml
from pathlib import Path
def get_declared_boundaries(tm_path: str) -> list[str]:
content = Path(tm_path).read_text()
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
meta = yaml.safe_load(match.group(1))
return meta.get("trust_boundaries", [])
def get_actual_sg_rules(sg_id: str) -> list[dict]:
ec2 = boto3.client("ec2")
resp = ec2.describe_security_groups(GroupIds=[sg_id])
return resp["SecurityGroups"][0]["IpPermissions"]
Run this check in a nightly job rather than on every PR — cloud API rate limits make it unsuitable for per-commit execution. Nightly failures create a GitHub issue tagged security/boundary-drift and assign it to the service owner from frontmatter.
Third-Party API Dependency Tracking
Maintain an explicit external_dependencies array. Automate synchronization by parsing package.json, go.mod, or requirements.txt during the CI pipeline. Map each dependency to its security posture — including SBOM status and CVE exposure — to support attack surface mapping that covers third-party ingress/egress channels.
external_dependencies:
- vendor: "Stripe"
endpoint: "/v1/charges"
auth: "OAuth2 + HMAC"
sla: "99.99%"
compliance: "PCI-DSS Level 1"
- vendor: "SendGrid"
endpoint: "/v3/mail/send"
auth: "API Key"
sla: "99.95%"
compliance: "SOC2-Type-II"
Verification
After wiring the pipeline, confirm the controls are working against both a passing and a failing test fixture.
# 1. Run the validator against a known-good file
python scripts/validate_threat_model.py \
--path threat-models/svc-payment-gateway.md \
--max-risk-threshold 14 \
--require-stride-tag \
--enforce-trust-boundaries
# Expected: exit 0, output "Validation passed: 2 threats, 0 errors"
# 2. Inject a bad STRIDE tag and confirm rejection
sed 's/Tampering/BadTag/' threat-models/svc-payment-gateway.md \
> /tmp/bad_stride.md
python scripts/validate_threat_model.py --path /tmp/bad_stride.md
# Expected: exit 1, output "Row T-001: invalid STRIDE tag 'BadTag'"
# 3. Generate a compliance artifact from the passing file
python scripts/compliance_export.py \
threat-models/svc-payment-gateway.md \
artifacts/compliance/
# Expected: creates artifacts/compliance/svc-payment-gateway_compliance_evidence.json
# with git_commit hash and export_timestamp populated
Spot-check the generated JSON artifact:
# scripts/compliance_export.py (excerpt)
import yaml, re, json, subprocess
from pathlib import Path
from datetime import datetime, timezone
def parse_threat_model(file_path: str) -> dict:
content = Path(file_path).read_text()
fm = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not fm:
raise ValueError("Missing YAML frontmatter")
meta = yaml.safe_load(fm.group(1))
table_match = re.search(
r'\| Threat ID \|.*?\n\|[-:|]+\n((?:\|.*\n)+)', content
)
threats = []
if table_match:
for row in table_match.group(1).strip().splitlines():
cols = [c.strip() for c in row.split('|')[1:-1]]
threats.append({
"threat_id": cols[0], "vector": cols[1],
"stride": cols[2], "risk_score": int(cols[5]),
"mitigation": cols[6], "status": cols[7]
})
meta["threats"] = threats
meta["export_timestamp"] = datetime.now(timezone.utc).isoformat()
meta["git_commit"] = subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=Path(file_path).parent
).decode().strip()
return meta
The git_commit field makes every exported artifact cryptographically traceable to its source commit, satisfying the immutability requirement for SOC 2 Type II and ISO 27001 evidence.
Troubleshooting
| Failure mode | Symptom | Diagnosis and fix |
|---|---|---|
| Frontmatter parse error | yaml.scanner.ScannerError on CI |
Unescaped colon or pipe in a string value. Wrap values containing : or | in double quotes. |
| Table row count mismatch | Validator reports 0 threats but file has rows | Separator row (` |
envsubst leaves literal ${} in substituted file |
Nightly boundary check fails with ARN not found | Variable not exported in the CI environment. Add the missing env: key to the workflow step that calls envsubst. |
| Risk gate passes a score ≥ 15 | High-risk threat not caught | Impact and Likelihood columns swapped. The parser reads column 3 as Impact and column 4 as Likelihood — match the header order exactly. |
Compliance export produces empty threats array |
JSON artifact has "threats": [] |
Regex match on the table header fails if extra whitespace appears after | Threat ID |. Run the file through a Markdown formatter that normalizes table padding before export. |
Related
- Threat Model Documentation Patterns — parent guide covering schema design, residual risk workflows, and audit retention
- How to Apply STRIDE to Microservices Architecture — applying STRIDE classification at the service boundary level that feeds these templates
- Automated Attack Surface Discovery with OWASP ZAP — surfacing undocumented data flows that belong in the external dependencies section
- Mapping Trust Boundaries in Cloud-Native Apps — aligning the
trust_boundariesfrontmatter field with actual network topology
Frequently Asked Questions
Can Markdown threat models replace traditional visual diagrams for complex architectures?
Markdown is the version-controlled source of truth for threat enumeration, risk scoring, and compliance mapping. For visualization, pair Markdown with auto-generated diagramming tools that parse the same YAML frontmatter. Decoupling documentation from rendering ensures diagrams never drift from the underlying threat data — a critical property for regulated environments where stale diagrams constitute a compliance gap.
How do I handle dynamic cloud assets that change per deployment?
Inject environment-specific variables into the Markdown template during the CI build phase using envsubst or a custom Jinja2 preprocessor. Populate runtime_assets and trust_boundaries from Terraform state outputs or CloudFormation exports. Run a pre-deploy hook that validates declared boundaries against live cloud network configurations, failing the pipeline if unapproved egress paths are detected. Commit only the parameterized template — never the substituted artifact — so the file remains environment-agnostic.
Does a Markdown-based workflow satisfy external auditor requirements?
Yes, when paired with immutable version control and automated compliance mapping. Git commit history provides cryptographic proof of review dates, approvers, and risk acceptance decisions. The export script transforms Markdown into structured JSON artifacts mapped to SOC 2 Trust Services Criteria (CC6.1, CC7.2) and ISO 27001 Annex A controls (A.12.6.1, A.14.2.1). Auditors receive timestamped, version-tracked evidence without requiring access to internal repositories — a pattern that also satisfies PCI DSS Requirement 6.3 secure SDLC documentation mandates.