Writing Custom Risk Rules with Scripts
March 12, 2026 ยท View on GitHub
This guide walks you through creating custom risk rules using the Threagile YAML-based script language. For a complete language reference, see language-reference.md. For testing your scripts, see testing.md.
Overview
Instead of writing risk rules in Go and compiling them as plugins, you can define risk rules as YAML scripts. These scripts are loaded at runtime and evaluated against the threat model just like built-in rules.
Script files are placed in the risk scripts directory (e.g., pkg/risks/scripts/) and have the .yaml extension.
Quick Start
Here is a minimal risk rule that flags all in-scope technical assets tagged with database:
id: unencrypted-database
title: Unencrypted Database
function: operations
stride: information-disclosure
cwe: 311
description: >
Databases should encrypt data at rest to protect against unauthorized access.
impact: >
If unmitigated, sensitive data could be exposed through physical access or backup theft.
asvs: "V8 - Data Protection Verification Requirements"
cheat_sheet: "https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html"
action: "Database Encryption"
mitigation: >
Enable transparent data encryption (TDE) or use encrypted storage volumes.
check: "Is data-at-rest encryption enabled for all databases?"
detection_logic: >
In-scope technical assets tagged with 'database'.
risk_assessment: >
The risk rating depends on the confidentiality of processed data assets.
false_positives: >
Databases that only store public, non-sensitive data.
risk:
id:
parameter: tech_asset
id: "{$risk.id}@{tech_asset.id}"
match:
parameter: tech_asset
do:
- if:
and:
- false: "{tech_asset.out_of_scope}"
- contains:
item: database
in: "{tech_asset.tags}"
then:
return: true
data:
parameter: tech_asset
title: "<b>Unencrypted Database</b> risk at <b>{tech_asset.title}</b>"
severity: "calculate_severity(likely, medium)"
exploitation_likelihood: likely
exploitation_impact: medium
data_breach_probability: probable
data_breach_technical_assets:
- "{tech_asset.id}"
most_relevant_technical_asset: "{tech_asset.id}"
Step-by-Step Guide
Step 1: Define the Risk Category Metadata
Start with the top-level fields that describe the risk category. These fields map directly to the RiskCategory type:
id: my-rule-id # unique identifier, used in risk tracking
title: My Rule Title # human-readable title
function: operations # business-side | architecture | development | operations
stride: information-disclosure # which STRIDE category
cwe: 200 # CWE number
The remaining metadata fields provide context for reports:
description: >
What this risk is about.
impact: >
What happens if unmitigated.
asvs: "ASVS chapter reference"
cheat_sheet: "URL to relevant OWASP cheat sheet"
action: "Short action title"
mitigation: >
How to mitigate. Supports <i>HTML</i> for emphasis.
check: "Verification question?"
detection_logic: >
When this rule triggers.
risk_assessment: >
How severity is determined.
false_positives: >
Known false positive scenarios.
Step 2: Define the Match Condition
The match: section filters which technical assets trigger a risk. The engine iterates over all technical assets and calls your match logic for each one.
Common patterns:
Filter by technology attribute:
match:
parameter: tech_asset
do:
- if:
and:
- false: "{tech_asset.out_of_scope}"
- any:
in: "{tech_asset.technologies}"
true: "{.attributes.web-application}"
then:
return: true
Filter by tag:
match:
parameter: tech_asset
do:
- if:
and:
- false: "{tech_asset.out_of_scope}"
- contains:
item: my-tag
in: "{tech_asset.tags}"
then:
return: true
Filter by multiple conditions using or:
match:
parameter: tech_asset
do:
- if:
and:
- false: "{tech_asset.out_of_scope}"
- or:
- contains:
item: database
in: "{tech_asset.tags}"
- any:
in: "{tech_asset.technologies}"
true: "{.attributes.database}"
then:
return: true
Step 3: Define the Risk Data
The data: section defines the fields of the generated risk object. Each field is evaluated as an expression.
For simple cases with fixed severity:
data:
parameter: tech_asset
title: "<b>My Rule</b> risk at <b>{tech_asset.title}</b>"
severity: "calculate_severity(unlikely, low)"
exploitation_likelihood: unlikely
exploitation_impact: low
data_breach_probability: possible
data_breach_technical_assets:
- "{tech_asset.id}"
most_relevant_technical_asset: "{tech_asset.id}"
For dynamic severity based on data asset classification, use a utility method (see Step 4).
Step 4: Add Utility Methods
For non-trivial logic, extract reusable methods into the utils: section.
Dynamic impact calculation based on processed data assets:
utils:
get_impact:
parameters:
- tech_asset
do:
- assign:
- impact: low
- highest_confidentiality: "get_highest({tech_asset}, confidentiality)"
- highest_integrity: "get_highest({tech_asset}, integrity)"
- highest_availability: "get_highest({tech_asset}, availability)"
- if:
or:
- equal-or-greater:
as: confidentiality
first: "{highest_confidentiality}"
second: confidential
- equal-or-greater:
as: criticality
first: "{highest_integrity}"
second: critical
- equal-or-greater:
as: criticality
first: "{highest_availability}"
second: critical
then:
- assign:
impact: medium
- if:
or:
- equal-or-greater:
as: confidentiality
first: "{highest_confidentiality}"
second: strictly-confidential
- equal-or-greater:
as: criticality
first: "{highest_integrity}"
second: mission-critical
- equal-or-greater:
as: criticality
first: "{highest_availability}"
second: mission-critical
then:
- assign:
impact: high
- return: "{impact}"
get_highest:
parameters:
- tech_asset
- "type"
do:
- assign:
- value: "{tech_asset.{type}}"
- loop:
in: "{tech_asset.data_assets_processed}"
item: data_id
do:
if:
greater:
first: "{$model.data_assets.{data_id}.{type}}"
second: "{value}"
as: "{type}"
then:
- assign:
value: "{$model.data_assets.{data_id}.{type}}"
- return: "{value}"
Then reference it from the data: section:
data:
parameter: tech_asset
title: "get_title({tech_asset})"
severity: "calculate_severity(unlikely, get_impact({tech_asset}))"
exploitation_likelihood: unlikely
exploitation_impact: "get_impact({tech_asset})"
# ...
Dynamic title based on asset tags:
utils:
get_title:
parameters:
- tech_asset
do:
- if:
contains:
item: git
in: "{tech_asset.tags}"
then:
- return:
"<b>Accidental Secret Leak (Git)</b> risk at <b>{tech_asset.title}</b>: <u>Git Leak Prevention</u>"
else:
- return:
"<b>Accidental Secret Leak</b> risk at <b>{tech_asset.title}</b>"
Step 5: Add Explanations (Optional)
Add explain statements and defer blocks to provide audit trails for why a risk was generated and how the severity was determined.
get_highest:
parameters:
- tech_asset
- "type"
do:
- defer:
- explain: "the highest {type} value of the technical asset is '{value}'"
- assign:
- value: "{tech_asset.{type}}"
- explain: "{type} value of the technical asset is '{value}'"
- loop:
in: "{tech_asset.data_assets_processed}"
item: data_id
do:
if:
greater:
first: "{$model.data_assets.{data_id}.{type}}"
second: "{value}"
as: "{type}"
then:
- assign:
value: "{$model.data_assets.{data_id}.{type}}"
- explain: "{type} value of data asset '{data_id}' is '{value}'"
- return: "{value}"
explain records a fact at the point of execution. defer ensures the explanation runs when the method exits, giving it access to the final variable values.
Complete Example
See the reference implementation: pkg/risks/scripts/accidental-secret-leak.yaml
This script implements the same logic as the Go built-in rule at pkg/risks/builtin/accidental_secret_leak_rule.go, providing a side-by-side comparison of both approaches.
Common Patterns
Check Technology Attributes
any:
in: "{tech_asset.technologies}"
or:
- true: "{.attributes.web-application}"
- true: "{.attributes.web-service-rest}"
Check Data Asset Properties via Model
- loop:
in: "{tech_asset.data_assets_processed}"
item: data_id
do:
if:
equal:
first: "{$model.data_assets.{data_id}.confidentiality}"
second: strictly-confidential
then:
- assign:
has_sensitive_data: true
Conditional Title Suffix
- if:
contains:
item: some-tag
in: "{tech_asset.tags}"
then:
- return: "<b>{$risk.title} (Special)</b> risk at <b>{tech_asset.title}</b>: <u>Details</u>"
else:
- return: "<b>{$risk.title}</b> risk at <b>{tech_asset.title}</b>"
Combining Multiple Data Asset Loops
- loop:
in: "{tech_asset.data_assets_processed}"
item: processed_id
do:
# check processed assets
- loop:
in: "{tech_asset.data_assets_stored}"
item: stored_id
do:
# check stored assets
Tips
- Always check
false: "{tech_asset.out_of_scope}"in your match condition to respect scoping. - Use
asin comparison expressions when comparing enum values (confidentiality, criticality, etc.) โ without it, the comparison is a plain string comparison. - Method names in
utils:are case-insensitive. - Variable names are case-insensitive.
- The
{.field}syntax only works insideloopandany/all/countiterations to reference the current item. - String values support HTML tags for formatting in report output (
<b>,<i>,<u>). - Use
calculate_severity(likelihood, impact)to compute severity consistently with Threagile's built-in calculation.