Defines a custom Defender for Cloud recommendation — its name, severity, effort, impact and threat categories. Subscription-scoped. Targets
hashicorp/azurerm ~> 4.0.
- ⚙️ Creates one
azurerm_security_center_assessment_policy, namedthis— the definition of a custom recommendation, not a verdict about any resource. - 🔴 One resource, three graded fields, two vocabularies.
severitytakesMedium;implementation_effortanduser_impacttakeModerate. No field accepts both, and the provider's generic value-set error does not mention the neighbour — so this module rejects each wrong word by name. - 🔴
categoriesacceptsUnknown, and it means nothing. The provider's source explains it was added only because the API returnsUnknownwhen the field is unset. Reported, not rejected. - 🧊
categoriesis optional AND computed, so leaving it unset is not the same as passing an empty set — this module passesnullso Azure's computed value survives. - 🆔 No
nameargument. Azure assigns a GUID;display_nameis the only human-chosen identifier. - ♻️ No argument is force-new — every field, including the grades and categories, updates in place.
💡 Why it matters: this is descriptive metadata, so almost nothing here can fail loudly. A mis-graded recommendation, a
Mediumtyped whereModeratebelongs, or anUnknowncategory all either apply cleanly or fail with an error that does not say what the neighbouring field wanted. The value is in closing the enums and naming the confusions.
If this module saves you time:
- ⭐ Star the repository — it helps others find it.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
flowchart TB
pricing["terraform-azurerm-security-center-subscription-pricing"]
policy["terraform-azurerm-security-center-assessment-policy
defines the recommendation, subscription-scoped"]
assessment["terraform-azurerm-security-center-assessment
asserts one result, nests under its target"]
stg["terraform-azurerm-storage-account"]
vmss["terraform-azurerm-virtual-machine-scale-set"]
atp["terraform-azurerm-advanced-threat-protection"]
pricing -.->|"Defender plan enables the posture surface"| policy
policy -->|"id as assessment_policy_id"| assessment
stg -->|"id as target_resource_id"| assessment
vmss -->|"id as target_resource_id"| assessment
atp -.->|"sibling that also targets any resource by id"| assessment
style policy fill:#0078D4,stroke:#004578,color:#ffffff
style assessment fill:#0078D4,stroke:#004578,color:#ffffff
style pricing fill:#004578,stroke:#004578,color:#ffffff
style stg fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style vmss fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style atp fill:#F3F6F9,stroke:#8A8886,color:#201F1E
This diagram is shared with terraform-azurerm-security-center-assessment, the other half of the pair — the
node text carries the difference. Both are highlighted: this module defines the recommendation and is
subscription-scoped; the assessment records one verdict and nests under whatever resource it assesses.
flowchart TB
subgraph inputs["Inputs, no resource group and no location"]
i1["display_name plus description"]
i2["severity: Low, Medium, High"]
i3["implementation_effort and user_impact: Low, Moderate, High"]
i4["categories, optional and computed"]
i5["threats"]
end
this["azurerm_security_center_assessment_policy.this
name is a computed GUID"]
subgraph outputs["Outputs"]
o1["id, the value the assessment consumes"]
o2["name, the computed GUID"]
o3["severity_uses_medium_while_effort_and_impact_use_moderate"]
o4["categories_include_the_unknown_api_artefact"]
o5["no_argument_is_force_new"]
end
i1 --> this
i2 --> this
i3 --> this
i4 --> this
i5 --> this
this --> o1
this --> o2
this --> o3
this --> o4
this --> o5
style this fill:#0078D4,stroke:#004578,color:#ffffff
style inputs fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style outputs fill:#F3F6F9,stroke:#8A8886,color:#201F1E
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_security_center_assessment_policy.this |
1 | The keystone. Subscription-scoped, name computed |
timeouts block |
0–1 | All four operations, because nothing is force-new |
| Item | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module — the caller configures the provider, its auth, and its features {} block |
| ARM API version | Microsoft.Security 2021-06-01 |
Schema notes that bite
- 🔴
severityis Low/Medium/High;implementation_effortanduser_impactare Low/Moderate/High. - 🔴
categoriesacceptsUnknownonly as an API round-trip artefact. - 🔴
categoriesis optional AND computed — unset differs from empty. - 🔴 No argument is force-new, so the generated GUID survives every edit.
⚠️ nameis computed — a GUID. Two policies may share adisplay_name.⚠️ severityhas a provider-declared default ofMedium, unusual in this family.⚠️ No resource group, no location, no tags. The ID sits directly under the subscription.⚠️ threatshas eight values; the docs and the source list them in different orders, same members.
| Principal | Permission | Scope | Why |
|---|---|---|---|
| The Terraform identity | Security Admin |
The subscription | Create custom recommendations — the role Microsoft names |
| The Terraform identity | Microsoft.Security/assessmentMetadata/write |
The subscription | Create and update |
| The Terraform identity | Microsoft.Security/assessmentMetadata/read |
The policy | Refresh and plan |
| The Terraform identity | Microsoft.Security/assessmentMetadata/delete |
The policy | Destroy |
Security Admin is what Microsoft names for creating custom recommendations. Owner is needed only to create a
custom standard, which is a different thing. Note the scope: the subscription, because this record has no
resource group.
Plan access is read-only in the useful sense. id and the generated name are the only computed
attributes.
Microsoft.Securityregistered on the subscription.Security Adminon the subscription.- A Defender for Cloud posture surface. Azure Policy-based custom recommendations are available to all
customers; the KQL-based experience requires the Defender CSPM plan. This resource is the
assessmentMetadataAPI path. - No resource group and no region to choose.
terraform-azurerm-security-center-assessment-policy/
├── providers.tf # required_version, pinned azurerm; no provider block
├── variables.tf # 9 deeply-typed inputs, 16 validations
├── main.tf # the keystone plus the grade and category facts
├── outputs.tf # id first, then the vocabulary trap, then what is only reported
├── README.md # this file
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
provider "azurerm" {
features {}
}
module "https_policy" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Storage accounts must enforce HTTPS"
description = "Detects storage accounts that permit plain HTTP connections."
severity = "High"
categories = ["Data"]
threats = ["DataExfiltration"]
implementation_effort = "Low"
user_impact = "Low"
remediation_description = "Set supportsHttpsTrafficOnly to true on the storage account."
}Note Low for effort and impact and High for severity — the words only collide at the middle grade.
Consumes: nothing. Every input is caller-supplied metadata, which is what makes this the root of the pair.
Emits
| Output | Description |
|---|---|
id |
Policy Resource ID, emitted first. The value the assessment module consumes |
name |
The computed GUID. Not what the sibling wants |
id_is_the_value_the_assessment_module_consumes |
Removes the ambiguity between the two above |
severity_uses_medium_while_effort_and_impact_use_moderate |
The trap, as an assertable fact |
categories_include_the_unknown_api_artefact |
A value that records nothing |
no_argument_is_force_new |
Change planning |
grades |
All three graded fields together |
1 · The minimum call
module "minimum" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Custom check"
description = "What this check looks for."
}ℹ️ Two of the nine inputs are required.
severitydefaults toMedium— and that default comes from the provider itself rather than from Azure, which is unusual in this family. Everything else is left unset so Azure supplies or omits it.
2 · The trap: Medium versus Moderate
# REJECTED at plan time by this module.
module "wrong_word" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Custom check"
description = "What this check looks for."
severity = "Moderate" # wrong: severity's middle value is Medium
implementation_effort = "Medium" # wrong: this field's middle value is Moderate
}🔒 One resource, three graded fields, two vocabularies:
severity Low | Medium | High implementation_effort Low | Moderate | High user_impact Low | Moderate | HighThe provider rejects the wrong word with a generic value-set error that never mentions the neighbouring field. This module rejects each one by name:
severity does not accept Moderate - that value belongs to implementation_effort and user_impact. This field's middle value is Medium.
severity_uses_medium_while_effort_and_impact_use_moderateis emitted so the fact is assertable rather than just documented.
3 · All three grades, correctly
module "graded" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Key Vaults must enable purge protection"
description = "Detects Key Vaults without purge protection, which permits permanent secret loss."
severity = "Medium" # Medium here
implementation_effort = "Moderate" # Moderate here
user_impact = "Moderate" # and here
}
output "the_three_grades" {
value = module.graded.grades
}💡 The
gradesoutput gathers all three into one map, because meeting these fields one at a time is exactly how the confusion arises. SeeingMedium,Moderate,Moderateside by side makes the split obvious.
4 · The `Unknown` category, reported not rejected
module "unknown_category" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Custom check"
description = "What this check looks for."
categories = ["Unknown"]
}
check "categories_say_something" {
assert {
condition = !module.unknown_category.categories_include_the_unknown_api_artefact
error_message = "categories contains Unknown, which records nothing about what this assessment covers. Either name the real categories or omit the field and let Azure compute it."
}
}
⚠️ Unknownis in the provider's accepted set only because the API returns it whencategoriesis unset — the provider's source says so, and links the upstream REST-spec issue. Choosing it deliberately records nothing. Accepted (the provider allows it) and reported, because refusing it would reject input the provider permits.
5 · Unset is not the same as empty
# Leaves Azure to compute the categories.
module "computed_categories" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Custom check"
description = "What this check looks for."
# categories deliberately omitted
}
output "did_the_caller_choose" {
value = {
explicit = module.computed_categories.categories_set_explicitly # false
value = module.computed_categories.categories # Azure's computed value
}
}🔒
categoriesis declared optional and computed. Omitting it lets Azure supply a value; passing[]writes an empty list over it. Those are different outcomes, so this module defaults the input tonullrather than to an empty set and reports which happened.
6 · Categories, spelled exactly
module "categorised" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Custom check"
description = "What this check looks for."
categories = ["Compute", "Data", "IdentityAndAccess", "IoT", "Networking"]
}
⚠️ NoteIdentityAndAccessandIoT— both easy to mis-case, and both matched case-sensitively. This module rejects a right-category-wrong-case value with a message saying so, rather than only listing the legal set.
7 · Threats
module "threat_mapped" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Storage accounts must enforce HTTPS"
description = "Detects storage accounts that permit plain HTTP connections."
threats = ["DataExfiltration", "DataSpillage"]
}ℹ️ Eight values:
AccountBreach,DataExfiltration,DataSpillage,DenialOfService,ElevationOfPrivilege,MaliciousInsider,MissingCoverage,ThreatResistance. The documentation lists them alphabetically and the provider source in a different order — same eight members, so the ordering difference is cosmetic rather than an asymmetry worth worrying about.
8 · A duplicate that only differs by case
# REJECTED at plan time by this module.
module "case_dupe" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Custom check"
description = "What this check looks for."
categories = ["Compute", "compute"]
}🔒 Two entries to Terraform, one value to Azure. This module rejects case-only duplicates in both set-valued fields, because the alternative is a plan that never settles.
9 · Remediation guidance, and why to assert on it
module "no_guidance" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Custom check"
description = "What this check looks for."
# remediation_description deliberately omitted
}
check "recommendations_say_how_to_fix_the_problem" {
assert {
condition = module.no_guidance.has_remediation_guidance
error_message = "This assessment policy states a problem and offers no route out of it. Set remediation_description."
}
}💡 The provider does not require remediation text, and a recommendation without it lands on whoever must fix the problem with nothing to act on. Not enforced here — it is an estate convention, not an Azure rule — so the
checkblock is the right severity.
10 · Nothing here is force-new
module "editable" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Storage accounts must enforce HTTPS (revised)"
description = "Revised wording after the security review."
severity = "High" # was Medium
categories = ["Data"] # was unset
}
output "guid_survives_edits" {
value = {
guid = module.editable.name
nothing_replaces = module.editable.no_argument_is_force_new
}
}♻️ Unusual, and worth relying on: the documentation carries no "changing this forces a new resource" statement for any field, and the schema declares no
ForceNew. So a policy can be renamed, re-graded and re-categorised in place, and the generated GUID does not change — which means every assessment already pointing at this policy keeps working across the edit.
11 · Which output the sibling wants
module "policy" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Storage accounts must enforce HTTPS"
description = "Detects storage accounts that permit plain HTTP connections."
severity = "High"
}
output "wiring" {
value = {
pass_this_one = module.policy.id # the full Resource ID
not_this_one = module.policy.name # the bare GUID
confirmed = module.policy.id_is_the_value_the_assessment_module_consumes
}
}🔒 This module emits two plausible identifiers and the assessment module wants
id. The confusion is reasonable: the GUID innamereappears as the last segment of the assessment's own Resource ID, so it looks like the linking value. It is not — the provider's validator wants the full ID.
12 · Several policies with `for_each`
locals {
checks = {
storage_https = {
display_name = "Storage accounts must enforce HTTPS"
description = "Detects storage accounts that permit plain HTTP connections."
severity = "High"
categories = ["Data"]
threats = ["DataExfiltration"]
}
kv_purge = {
display_name = "Key Vaults must enable purge protection"
description = "Detects Key Vaults without purge protection."
severity = "Medium"
categories = ["Data"]
threats = ["DataSpillage"]
}
nsg_wildcards = {
display_name = "NSGs must not use wildcard rules"
description = "Detects network security groups with any-any rules."
severity = "High"
categories = ["Networking"]
threats = ["ElevationOfPrivilege"]
}
}
}
module "policies" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
for_each = local.checks
display_name = each.value.display_name
description = each.value.description
severity = each.value.severity
categories = each.value.categories
threats = each.value.threats
implementation_effort = "Moderate"
user_impact = "Low"
}
output "policy_ids_by_check" {
value = { for k, m in module.policies : k => m.id }
}💡 A keyed map, so removing one check never re-indexes the others — and the output map is exactly the shape the assessment module consumes, one
idper check.
13 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
module "defender_plan" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-subscription-pricing.git?ref=v1.0.0"
tier = "Standard"
resource_type = "StorageAccounts"
}
module "app_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-app-eastus2"
location = "eastus2"
tags = { workload = "shop", managed_by = "terraform" }
}
module "app_storage" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
name = "stshopeastus2"
location = module.app_rg.location
resource_group_name = module.app_rg.name
tags = { workload = "shop", managed_by = "terraform" }
}
# The recommendation definition. Subscription-scoped: no resource group, no location.
module "https_policy" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment-policy.git?ref=v1.0.0"
display_name = "Storage accounts must enforce HTTPS"
description = "Detects storage accounts that permit plain HTTP connections."
severity = "High"
implementation_effort = "Low"
user_impact = "Low"
categories = ["Data"]
threats = ["DataExfiltration"]
remediation_description = "Set supportsHttpsTrafficOnly to true on the storage account."
}
# The verdict for one resource. Nests under the storage account, and asserts rather than measures.
module "https_verdict" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-security-center-assessment.git?ref=v1.0.0"
# The policy's id, NOT its name.
assessment_policy_id = module.https_policy.id
target_resource_id = module.app_storage.id
status = {
code = "Healthy"
cause = "supportsHttpsTrafficOnly is true"
description = "Verified by the platform compliance scanner."
}
additional_data = {
checked_by = "platform-compliance-scanner"
checked_at = "2026-07-31T18:00:00Z"
}
}
check "the_recommendation_tells_you_how_to_fix_it" {
assert {
condition = module.https_policy.has_remediation_guidance
error_message = "The assessment policy has no remediation_description, so the recommendation states a problem with no route out of it."
}
}
check "the_categories_are_meaningful" {
assert {
condition = !module.https_policy.categories_include_the_unknown_api_artefact
error_message = "categories contains Unknown, which records nothing about what the assessment covers."
}
}
check "the_verdict_records_how_it_was_reached" {
assert {
condition = !module.https_verdict.records_no_provenance
error_message = "The assessment records no additional_data, so nobody can tell how or when the verdict was determined - and nothing will ever re-evaluate it."
}
}
output "custom_recommendation" {
value = {
policy_id = module.https_policy.id
policy_guid = module.https_policy.name
grades = module.https_policy.grades
verdict = module.https_verdict.status_code
verdict_is_asserted = module.https_verdict.this_records_an_asserted_result_not_a_computed_one
verdict_is_frozen = module.https_verdict.the_status_will_not_change_until_terraform_changes_it
}
}🏗️ Read the two
verdict_is_values. Both aretruefor every configuration: theHealthyresult above is a claim this configuration makes, not something Azure measured, and it will keep reportingHealthyeven if someone disables HTTPS-only on that storage account tomorrow. That is what a custom assessment is — and it is why the composition asserts onrecords_no_provenance, sinceadditional_datais the only evidence of when the claim was last true.Note also
module.https_policy.idfeedingassessment_policy_id, notmodule.https_policy.name. The GUID innamereappears inside the assessment's own ID, which makes the wrong choice look right.
Required (2)
| Name | Type | Description |
|---|---|---|
display_name |
string |
User-facing recommendation name. |
description |
string |
What the assessment checks. |
Optional (7)
| Name | Type | Default | Description |
|---|---|---|---|
severity |
string |
"Medium" |
Low/Medium/High. Provider-declared default. |
categories |
set(string) |
null |
Optional and computed — null preserves Azure's value. |
implementation_effort |
string |
null |
Low/Moderate/High. |
user_impact |
string |
null |
Low/Moderate/High. |
threats |
set(string) |
null |
Any of eight values. |
remediation_description |
string |
null |
How to fix it. |
timeouts |
object(...) |
null |
Go durations; all four operations. |
Full input schemas
variable "severity" {
type = string
default = "Medium"
# Low | Medium | High -- note MEDIUM, not Moderate. Case-sensitive.
}
variable "implementation_effort" {
type = string
default = null
# Low | Moderate | High -- note MODERATE, not Medium. Case-sensitive.
}
variable "user_impact" {
type = string
default = null
# Low | Moderate | High -- same vocabulary as implementation_effort.
}
variable "categories" {
type = set(string)
default = null
# Unknown | Compute | Data | IdentityAndAccess | IoT | Networking
# null (not []) because the field is optional AND computed: an empty set would
# overwrite what Azure computes. Unknown is an API artefact and is reported.
}
variable "threats" {
type = set(string)
default = null
# AccountBreach | DataExfiltration | DataSpillage | DenialOfService
# ElevationOfPrivilege | MaliciousInsider | MissingCoverage | ThreatResistance
}This resource supports neither
tagsnorlocationand has noresource_group_name, so this module carries none of the usual universal tail beyondtimeouts.
| Output | Description | Notes |
|---|---|---|
id |
Policy Resource ID | Emitted first; what the sibling consumes |
name |
The computed GUID | Not the linking value |
id_is_the_value_the_assessment_module_consumes |
Constant true |
Removes the ambiguity |
name_is_a_computed_guid_not_a_chosen_name |
Constant true |
|
is_subscription_scoped_with_no_resource_group_or_location |
Constant true |
|
no_argument_is_force_new |
Constant true |
The GUID survives edits |
display_name / description |
User-facing text | |
severity / implementation_effort / user_impact |
The three grades | |
grades |
All three together | Makes the split visible |
severity_uses_medium_while_effort_and_impact_use_moderate |
Constant true |
Read this one |
categories / categories_set_explicitly |
What is at risk | Unset ≠ empty |
categories_include_the_unknown_api_artefact |
A meaningless value | check blocks |
threats / threats_set_explicitly |
Threat impacts | |
remediation_description / has_remediation_guidance |
The route out | check blocks |
accepts_no_credential |
Constant true |
No secret is emitted. Nothing here is marked
sensitive, because every field is written to be displayed.
Two vocabularies for one idea, inside one resource. severity grades on Low/Medium/High;
implementation_effort and user_impact grade on Low/Moderate/High. Nothing accepts both words. The
provider rejects the wrong one with a value-set error that never mentions the neighbour, which is precisely why
this module adds three checks rather than relying on the closed sets: each names where the offending word does
belong.
A value that exists to make a round-trip work. categories accepts Unknown, and the provider's own source
explains why — the API returns Unknown when the field is unset, so the value had to be accepted for reads to
round-trip. It records nothing when chosen deliberately. Accepted here (the provider allows it) and reported,
because refusing it would reject input the provider permits.
Optional and computed are different from optional. categories is both, so an unset input is not an empty
list: omitting the field lets Azure compute a value, while passing [] writes an empty list over it. The module
defaults to null for that reason and reports which path was taken.
Nothing is force-new, and that is load-bearing. Every field updates in place, so the GUID Azure generated never changes — which means every assessment already pointing at this policy keeps working across a rename, a re-grade or a re-categorisation. On most resources force-new facts are a warning; here their absence is a feature worth relying on.
Two identifiers, one of which is a decoy. id is what the assessment module consumes; name is the bare
GUID. The confusion is reasonable, because that same GUID becomes the last segment of the assessment's own
Resource ID — so it looks like the linking value while not being it.
No resource group, no location, no tags. The record sits directly under the subscription. The universal tail this library applies almost everywhere does not apply, and there is nothing here to tag.
Everything here is published text. The display name, description and remediation guidance are shown in Defender for Cloud to anyone with reader access to the subscription's posture. That is the reason nothing is marked sensitive — and the reason nothing confidential should be written into them.
| Concern | This module's behaviour | Opt-out |
|---|---|---|
| Grade vocabularies | wrong-word transpositions rejected by name | none; they cannot work |
| Enum casing | right-value-wrong-case rejected with a pointed message | none |
categories unset |
passed as null so Azure's computed value survives |
pass a set to override |
Unknown category |
accepted and reported | assert on the output |
| Set duplicates | case-only duplicates rejected | none; they never converge |
severity |
Medium, mirroring the provider's own declared default |
set Low or High |
| Secrets | none accepted, none emitted, none belongs here | none |
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module with ?ref=v1.0.0 — never a branch. This module is plan-only in CI; a human applies.
terraform validate proves the configuration parses and every type resolves; terraform fmt -check proves the
formatting. Neither runs a variable validation block on this module as a called module — but
terraform console does, when this directory is the root:
echo 'null' | terraform console -var-file=bad.tfvarsAll 16 validations were proven to fire this way, identified by line number rather than message text, with
terraform fmt run before the proof and the numbers re-confirmed afterwards.
Six checks necessarily co-fire with a companion, structurally rather than accidentally: a wrong-vocabulary word,
a wrong-cased value and a case-only duplicate are each by definition also outside the case-sensitive value
set, so the broad in-set check fires alongside the narrow one. The categories duplicate fixture trips three
checks at once for exactly that reason. In each case the broad check catches the value and the narrow one
supplies the explanation.
Seven good fixtures were confirmed to fire nothing, covering every branch that must not be rejected: all
three grades at their correct middle values, the Unknown artefact (legal input that must be reported), all
five categories, all eight threats, and a fully populated call.
What only a real plan or apply can exercise: whether the subscription has a Defender posture surface, and
what GUID Azure assigns.
id = "/subscriptions/.../providers/Microsoft.Security/assessmentMetadata/11111111-2222-3333-4444-555555555555"
name = "11111111-2222-3333-4444-555555555555"
display_name = "Storage accounts must enforce HTTPS"
severity = "High"
implementation_effort = "Low"
user_impact = "Low"
grades = {
"implementation_effort" = "Low"
"severity" = "High"
"user_impact" = "Low"
}
severity_uses_medium_while_effort_and_impact_use_moderate = true
categories = ["Data"]
categories_set_explicitly = true
categories_include_the_unknown_api_artefact = false
threats = ["DataExfiltration"]
threats_set_explicitly = true
has_remediation_guidance = true
no_argument_is_force_new = true
is_subscription_scoped_with_no_resource_group_or_location = true
id_is_the_value_the_assessment_module_consumes = true
accepts_no_credential = true
| Symptom | Cause | Fix |
|---|---|---|
severity = "Moderate" rejected |
severity's middle value is Medium |
Use Medium; Moderate belongs to effort and impact |
implementation_effort = "Medium" rejected |
That field's middle value is Moderate |
Use Moderate |
| A category was rejected that looks right | Case-sensitive; IdentityAndAccess and IoT are easy to mis-case |
Match the documented spelling exactly |
categories shows a value nobody set |
The field is computed, so Azure supplied it | Expected; see categories_set_explicitly |
categories became empty after a change |
An empty set was passed, overwriting the computed value | Omit the field instead of passing [] |
Unknown appears in the portal |
The field was unset, so the API returned Unknown |
Name the real categories, or accept it |
| Two policies look identical in the portal | There is no name argument; display_name need not be unique |
Distinguish them by GUID, or vary the display names |
| The assessment module rejected the policy value | The GUID from name was passed instead of id |
Pass id; see id_is_the_value_the_assessment_module_consumes |
| A rename replaced nothing | Correct — no argument is force-new | Expected; the GUID survives |
| Apply fails with an authorization error | The identity lacks Security Admin on the subscription |
Grant it; Owner is only needed for a custom standard |
| A timeout appears to have no effect | Misspelled timeouts key, silently discarded |
Check against the four declared keys |
| Cannot find the policy by resource group | It is subscription-scoped | Its ID has no resourceGroups segment |
azurerm_security_center_assessment_policy- Create custom standards and recommendations in Microsoft Defender for Cloud
- Security policies in Defender for Cloud
- Sibling modules:
terraform-azurerm-security-center-assessment,terraform-azurerm-security-center-subscription-pricing,terraform-azurerm-advanced-threat-protection - This module's
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."

