If you're using threatcl, you've already bought into the idea that threat models belong in version control, right next to the code they describe. HCL in, structured threat models out, all reviewable in a pull request.

But there's always been a gap, and it came into focus during a recent catch-up with Sam "Frenchie" Stewart. We were exploring use cases for threatcl, and Frenchie mentioned that validate wasn't meeting his expectations for the threat modelling he and his colleagues were doing. Fair call mate. While threatcl could tell you whether a threat model was syntactically valid, it couldn't tell you whether it followed your organisation's rules. "Every internet-facing feature must document audit logging" is exactly the kind of policy a security team writes down in a wiki, repeats in every review, and then watches drift one merged PR at a time.

As of threatcl 0.6.0, that policy can be code too. The new release adds invariants: org-wide, machine-checked rules that threatcl validate runs against every threat model it validates. And Frenchie didn't stop at flagging the gap, he went and contributed the feature itself in #186. Cheers, Frenchie 🍻

🕰 Prior art: invariants aren't the first swing at policy-as-code here. The repo has shipped example semgrep rules for a while now, pattern-matching your threat model HCL to flag things like threats with no expanded_control, controls that aren't implemented, or low risk_reduction values. That approach worked, and still does, but it operates on the raw source text of a model. Invariants are the next iteration of the same idea: they evaluate the fully parsed model (with extends inheritance applied and imports resolved) and add severities, when filters, and auditable exemptions as proper features.

The one-liner

threatcl validate -invariants=invariants.hcl ./models/

threatcl validate does what it always did (validates your threat model files) and then evaluates every invariant against every validated model. Error-severity violations fail the run with a non-zero exit code, so CI can enforce your rules for you.

What an invariant looks like

Invariants live in their own HCL file, deliberately separate from your threat models. One rule set can govern a whole fleet of models, and just as importantly, the people who own the rules don't have to be the people who own the models.

invariant "no_unauthenticated_public_endpoints" {
  description = "No public endpoints should be unauthenticated"
  severity    = "error"
  target      = "process"
  when        = item.trust_zone == "Public"
  condition   = anytrue([for c in tm.controls : c.implemented && can(regex("(?i)auth", c.name))])
}

Reading it top to bottom:

  • target picks the collection the rule applies to. Here that's every process in every data flow diagram. The condition runs once per item, so violations name the exact offending process rather than a vague "something in this model is wrong".

  • when filters which items the rule cares about. Processes outside the Public trust zone are skipped.

  • condition is a native HCL expression that must be true for each targeted item. If you've written Terraform you'll feel right at home: anytrue, alltrue, for expressions, regex, can, try, all the usual suspects.

Three variables are in scope: item (the current target item), tm (the threat model that owns it), and dfd (the owning diagram, for DFD element targets). So a rule about a single process can still reach up and ask questions about the whole model's controls, as the example above does.

Thirteen targets, from whole models down to single flows

Rules can target the threatmodel itself, each threat, each control, information_asset, usecase, exclusion, or third_party_dependency. On the diagram side there's each data_flow_diagram, process, external_element, data_store, flow, and trust_zone (nested elements included).

The granularity is what makes this useful. "No flow may use plain HTTP" is a one-liner:

invariant "no_plaintext_http" {
  description = "No data flow may use unencrypted HTTP"
  target      = "flow"
  condition   = lower(item.protocol) != "http"
}

And when it fires, the violation names the flow, the diagram, the model, and the file it came from.

Rolling out a rule without breaking everyone's Friday

A brand-new rule run against an existing fleet of models is going to fail. That's why you wrote it. Invariants support two severities so the rollout doesn't have to hurt:

  1. Introduce the rule as severity = "warning". Violations are reported, but CI stays green.

  2. Fix the fleet at your own pace, watching the warning count drop.

  3. Flip it to "error" (the default). From now on, no new model can regress.

Exemptions: waivers with a paper trail

Real fleets have legacy systems. Rather than weakening the rule for everyone, an invariant can carry an exemption for a specific model, with a required justification so every waiver is auditable:

invariant "internet_facing_models_document_audit_logging" {
  description = "All internet-facing features must emit audit logs"
  severity    = "warning"
  target      = "threatmodel"
  when        = item.attributes.internet_facing
  condition   = anytrue([for c in tm.controls : can(regex("(?i)audit", c.name))])

  error_message = "threatmodel '${item.name}' is internet-facing but documents no audit logging control"

  exemption {
    model         = threatmodel["Legacy Public API"]
    justification = "Grandfathered until Q3 migration; tracked in SEC-123"
  }
}

A couple of design decisions here were very deliberate.

Exemptions live in the invariants file, not the threat model. Models can't waive rules for themselves. The team that owns the policy owns the exceptions to it.

The model reference is a real object, not a string. threatmodel is a registry of the models in the current validate run, addressable by display name (threatmodel["Legacy Public API"]) or by dotted identifier (threatmodel.legacy_public_api, or threatmodel.buildings.tower for the namespaced ids that spec 0.5.2 introduced). If you reference a model that isn't in the run (a typo, or a model that got renamed) validation fails loudly and lists the models that are present. A renamed model can't quietly leave behind a dead waiver that exempts nothing.

Exempted models are skipped, and reported with their justification, so the waiver shows up in every run's output rather than hiding in a config file.

What it looks like in practice

$ threatcl validate -invariants=invariants.hcl ./models/
Validated 4 threatmodels in 3 files
Invariant 'internet_facing_models_document_audit_logging' exempts threatmodel 'Legacy Public API' (models/legacy.hcl): Grandfathered until Q3 migration; tracked in SEC-123
Invariant violation [error] 'threats_have_implemented_controls': threat 'Credential theft' in threatmodel 'Payments' (models/payments.hcl): Every threat must have at least one implemented control
Checked 3 invariants against 4 threatmodels: 1 errors, 0 warnings, 1 exemptions

One deliberate behaviour: if an invariant expression fails to evaluate (a typo'd field name, a bad regex) that's a bug in the rule, and it fails the run loudly. A broken rule that silently skips is worse than no rule at all.

Why this matters

Security review doesn't scale by adding reviewers. It scales by turning judgement into checks that run on every commit. Threat modelling as code gave us reviewable, diffable models. Invariants close the loop, because the standards those models are held to are now code as well: versioned, reviewed in PRs, and enforced in CI.

Start small. Pick the one rule your team repeats most often in reviews, write it as a warning, and let the tooling do the heavy lifting for you.

Get started

  • Read the full invariants documentation for every target, every field on tm/item/dfd, and the full function list

  • Run it in CI: threatcl validate -invariants=invariants.hcl ./models/ (it also works with -stdin/-stdinjson)

Keen to hear what rules you end up writing. Open an issue or discussion on GitHub.