Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Security Center Assessment Policy Terraform Module

Defines a custom Defender for Cloud recommendation — its name, severity, effort, impact and threat categories. Subscription-scoped. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • ⚙️ Creates one azurerm_security_center_assessment_policy, named this — the definition of a custom recommendation, not a verdict about any resource.
  • 🔴 One resource, three graded fields, two vocabularies. severity takes Medium; implementation_effort and user_impact take Moderate. 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.
  • 🔴 categories accepts Unknown, and it means nothing. The provider's source explains it was added only because the API returns Unknown when the field is unset. Reported, not rejected.
  • 🧊 categories is optional AND computed, so leaving it unset is not the same as passing an empty set — this module passes null so Azure's computed value survives.
  • 🆔 No name argument. Azure assigns a GUID; display_name is 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 Medium typed where Moderate belongs, or an Unknown category 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.


❤️ Support this project

If this module saves you time:


🗺️ Where this fits in the family

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
Loading

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.


🧬 What this module builds

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
Loading

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

✅ Provider / Versions

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

  • 🔴 severity is Low/Medium/High; implementation_effort and user_impact are Low/Moderate/High.
  • 🔴 categories accepts Unknown only as an API round-trip artefact.
  • 🔴 categories is optional AND computed — unset differs from empty.
  • 🔴 No argument is force-new, so the generated GUID survives every edit.
  • ⚠️ name is computed — a GUID. Two policies may share a display_name.
  • ⚠️ severity has a provider-declared default of Medium, unusual in this family.
  • ⚠️ No resource group, no location, no tags. The ID sits directly under the subscription.
  • ⚠️ threats has eight values; the docs and the source list them in different orders, same members.

🔑 Required Azure RBAC Roles / Permissions

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.

⚠️ Every field here is written to be displayed. The display name, description and remediation text appear in Defender for Cloud to anyone with reader access to the subscription's security posture. Nothing confidential belongs in them.


Azure Prerequisites

  • Microsoft.Security registered on the subscription.
  • Security Admin on 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 assessmentMetadata API path.
  • No resource group and no region to choose.

📁 Module Structure

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

⚙️ Quick Start

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.


🔌 Cross-Module Contract

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

📚 Example Library

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. severity defaults to Medium — 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 | High

The 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_moderate is 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 grades output gathers all three into one map, because meeting these fields one at a time is exactly how the confusion arises. Seeing Medium, Moderate, Moderate side 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."
  }
}

⚠️ Unknown is in the provider's accepted set only because the API returns it when categories is 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
  }
}

🔒 categories is 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 to null rather 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"]
}

⚠️ Note IdentityAndAccess and IoT — 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 check block 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 in name reappears 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 id per 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 are true for every configuration: the Healthy result above is a claim this configuration makes, not something Azure measured, and it will keep reporting Healthy even if someone disables HTTPS-only on that storage account tomorrow. That is what a custom assessment is — and it is why the composition asserts on records_no_provenance, since additional_data is the only evidence of when the claim was last true.

Note also module.https_policy.id feeding assessment_policy_id, not module.https_policy.name. The GUID in name reappears inside the assessment's own ID, which makes the wrong choice look right.


📥 Inputs

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 tags nor location and has no resource_group_name, so this module carries none of the usual universal tail beyond timeouts.


🧾 Outputs

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.


🧠 Architecture Notes

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.


🧱 Design Principles

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

⚠️ The secure-by-default rule has nothing to act on, and the module says so rather than reproducing an empty table. Two of nine inputs are required and none of the optional ones gates exposure — this resource is descriptive metadata about a recommendation, not a control over any resource. What the module does instead is close every enum and refuse the two vocabulary transpositions.


🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 — never a branch. This module is plan-only in CI; a human applies.


🧪 Testing

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.tfvars

All 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.


💬 Example Output

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

🔍 Troubleshooting

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

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."