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) or js-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 main are blocked for files under threat-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

Threat Model CI/CD Pipeline A five-stage pipeline diagram showing the flow from Pull Request through YAML Lint, STRIDE Tag Check, Risk Gate, and Compliance Export to Merge Allowed. Pull Request YAML Frontmatter Lint STRIDE Tag Check Risk Score Gate (≥15) Compliance Export + Merge fail: missing fields fail: invalid tag fail: open critical Validation stage Blocking gate Success / export

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.


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.