| title | Drift Detection and Remediation Strategies in Azure Terraform Pipelines | |||||
|---|---|---|---|---|---|---|
| author | Randy Bordeaux | |||||
| date | 2026-01-22 | |||||
| version | 1.0 | |||||
| services |
|
|||||
| categories |
|
Deployable Terraform: examples/devops/drift-detection-remediation/
- Table of Contents
- Scope and Drift Control Objectives
- Understanding Drift in Azure Environments
- Understanding Drift in Azure Environments
- Drift Detection Architecture
- Terraform-Based Drift Detection Pipelines
- Signal Analysis and Risk Classification
- Remediation Strategies and Promotion Controls
- Security, Compliance, and Auditability
- Operational Playbooks and Incident Handling
- Anti-Patterns and Common Failures
- Tradeoffs and Design Considerations
- Conclusion
- Drift Detection Architecture
- Terraform-Based Drift Detection Pipelines
- Signal Analysis and Risk Classification
- Automated Drift Analysis and Parsing
This whitepaper defines enterprise-grade strategies for detecting, analyzing, and remediating infrastructure drift in Azure environments managed with Terraform. Drift is treated as an expected operational condition, not an anomaly, and must be continuously detected, assessed, and corrected through controlled pipelines.
The guidance targets experienced Azure engineers operating Azure Commercial environments at scale. Terraform remains the authoritative infrastructure-as-code (IaC) mechanism, and drift remediation is executed exclusively through CI/CD pipelines with embedded governance and security controls.
Key Outcomes:
- Continuous drift detection across all environments with automated classification
- Risk-based remediation workflows with appropriate approval gates
- Comprehensive audit trail for compliance and security investigations
- Integration with Azure Monitor for real-time alerting and incident response
- Scope and Drift Control Objectives
- Understanding Drift in Azure Environments
- Drift Detection Architecture
- Terraform-Based Drift Detection Pipelines
- Signal Analysis and Risk Classification
- Remediation Strategies and Promotion Controls
- Security, Compliance, and Auditability
- Operational Playbooks and Incident Handling
This document addresses infrastructure drift in Azure environments where Terraform is the authoritative source of truth. Drift is defined as any divergence between deployed Azure resources and their declared Terraform state.
In Scope:
- Continuous automated drift detection across all environments
- Risk-based classification of drift signals
- Controlled remediation through CI/CD pipelines
- Audit logging and compliance reporting
- Integration with incident management systems
Out of Scope:
- Manual Azure Portal corrections (explicitly prohibited)
- Drift in non-Terraform-managed resources
- Application-level configuration drift
- Kubernetes workload drift (covered separately)
| Objective | Description | Implementation |
Drift occurs when the actual state of Azure resources diverges from the expected state declared in Terraform configuration files. This divergence can be detected by comparing terraform plan output against the current Terraform state and live Azure resources.
| Cause | Frequency | Risk Level | Example |
|---|---|---|---|
| Manual Portal Changes | High | High | Developer adds NSG rule via Portal |
| Emergency Break-Glass Operations | Low | Critical | Security team blocks IP during incident |
| Azure Platform Updates | Medium | Low | Azure adds default tags to resources |
| Terraform Provider Evolution | Low | Medium | Provider updates default values |
| Auto-Scaling Events | High | Low | VMSS scales up/down based on metrics |
| Azure Policy Remediation | Medium | Medium | Policy adds required tags automatically |
| Backup Service Changes | Medium | Low | Azure Backup modifies retention policies |
| Managed Identity Assignments | Low | High | Application adds role assignments |
Critical Distinction: Not all drift is bad. Some drift is:
- Expected: Auto-scaling, platform-managed updates
- Authorized: Emergency break-glass with proper documentation
- Harmless: Cosmetic changes like tag formatting
All drift requires investigation to determine intent, risk, and appropriate response.
graph TD
Drift[Detected Drift] --> Classification{Risk Classification}
Classification --> Low[Low Risk]
Classification --> Medium[Medium Risk]
Classification --> High[High Risk]
Classification --> Critical[Critical Risk]
Low --> Review[Review in Batch]
Medium --> Approval[Require Approval]
High --> Incident[Create Incident]
Critical --> Emergency[Emergency Response]
Review --> Remediate[Scheduled Remediation]
Approval --> Remediate
Incident --> Investigate[Security Investigation]
Emergency --> Investigate
Investigate --> Remediate
-
ISO 27001 A.12.1.2: "Change management ensures changes are implemented in a controlled manner" Control objectives include:
-
Early detection before production impact
-
No out-of-band remediation
-
Explicit approval for production corrections
Drift occurs when deployed resources diverge from declared Terraform state.
Common causes:
- Manual changes in the Azure Portal
- Emergency break-glass operations
- Platform-managed changes
- Provider or API evolution
Drift does not automatically imply misconfiguration, but it always requires investigation.
Drift detection is implemented as a non-mutating control pipeline.
graph LR
Schedule --> Plan
Plan --> DriftAnalysis
DriftAnalysis --> Report
DriftAnalysis --> Alert
Key characteristics:
- No
terraform apply - Read-only permissions
- Scheduled or event-driven execution
terraform initagainst live stateterraform planwithout apply- Plan output parsed for changes
Terraform plans are treated as diagnostic artifacts.
graph TD
LiveState --> TerraformPlan
TerraformPlan --> DriftReport
- Dev/Test: daily or per-commit
- Production: scheduled (e.g., nightly)
Detected drift must be classified.
| Drift Type | Risk Level | Action |
|---|---|---|
| Tag changes | Low | Review |
| Scaling changes | Medium | Approval |
| Network / IAM changes | High | Immediate escalation |
Automated classification reduces noise and focuses human attention.
- No direct portal fixes
- Fix-forward via Terraform code
- Same promotion and approval model as deployments
graph LR
DriftDetected --> CodeUpdate
CodeUpdate --> Plan
Plan --> Approval
Approval --> Apply
- Manual approval required
- Plan diff reviewed explicitly
- Emergency paths audited
Drift remediation is treated as a change event, not maintenance.
- Read-only identities for detection
- No state modification during detection
- Secure storage of drift artifacts
- Drift reports retained
- Remediation actions traceable
- Identity and timestamp recorded
Supports alignment with NIST 800-171 change control objectives.
Recommended playbooks:
- Low-risk drift: batch remediation
- High-risk drift: incident response
- Unknown drift: freeze and investigate
Drift involving IAM, networking, or encryption is escalated as a security incident.
Avoid:
- Auto-applying drift corrections
- Ignoring persistent drift
- Manual state edits
- Disabling detection pipelines
These practices erode trust in IaC.
- Increased pipeline execution cost
- Operational overhead
- Early issue detection
- Strong audit posture
- Predictable infrastructure state
Drift detection and remediation are continuous governance functions. When implemented correctly, they preserve Terraform as the single source of truth while enabling safe, auditable correction of real-world changes in Azure environments.
End of Document
Drift detection is implemented as a non-mutating control pipeline that runs independently from deployment pipelines.
graph TB
subgraph "Drift Detection Pipeline"
Schedule[Scheduled Trigger<br/>Daily 2AM UTC]
Event[Event Trigger<br/>Portal Change Detected]
Schedule --> Init[Terraform Init<br/>Read-Only Mode]
Event --> Init
Init --> Plan[Terraform Plan<br/>-detailed-exitcode<br/>-out=drift.tfplan]
Plan --> Convert[Convert Plan to JSON<br/>terraform show -json]
Convert --> Parse[Parse Changes<br/>PowerShell/Python]
Parse --> Classify{Classify Drift<br/>Risk Level}
Classify --> Low[Low Risk<br/>Tags, Descriptions]
Classify --> Medium[Medium Risk<br/>Scaling, Non-Critical]
Classify --> High[High Risk<br/>Network, IAM, Encryption]
Low --> Report[Generate Report<br/>Store Artifact]
Medium --> Report
High --> Alert[Azure Monitor Alert<br/>Create Incident]
Report --> Store[Azure Storage<br/>Immutable Blob]
Alert --> Store
Store --> Notify[Notification<br/>Teams/Email]
end
subgraph "Permissions Model"
DetectionSP[Detection Service Principal<br/>Reader + Storage Write]
RemediationSP[Remediation Service Principal<br/>Contributor + State Write]
end
Key Architectural Principles:
- Read-Only Execution: Detection pipeline has no write permissions to Azure resources
- State Isolation: Detection uses read-only state backend access
- Artifact Preservation: All drift plans stored as immutable artifacts
- Separate Identity: Detection and remediation use different service principals
- No Auto-Remediation: Human approval always required for production
# Terraform - Configure detection service principal
resource "azuread_application" "drift_detection" {
display_name = "terraform-drift-detection"
owners = [data.azuread_client_config.current.object_id]
}
resource "azuread_service_principal" "drift_detection" {
client_id = azuread_application.drift_detection.client_id
owners = [data.azuread_client_config.current.object_id]
}
resource "azuread_application_password" "drift_detection" {
application_id = azuread_application.drift_detection.id
display_name = "Drift Detection Pipeline"
end_date = timeadd(timestamp(), "8760h") # 1 year
}
# Read-only access to Azure resources
resource "azurerm_role_assignment" "drift_detection_reader" {
scope = data.azurerm_subscription.primary.id
role_definition_name = "Reader"
principal_id = azuread_service_principal.drift_detection.object_id
}
# Write access to drift report storage
resource "azurerm_role_assignment" "drift_detection_storage" {
scope = azurerm_storage_account.drift_reports.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azuread_service_principal.drift_detection.object_id
}
# Read-only access to Terraform state
resource "azurerm_role_assignment" "drift_detection_state" {
scope = azurerm_storage_account.tfstate.id
role_definition_name = "Storage Blob Data Reader"
principal_id = azuread_service_principal.drift_detection.object_id
}# azure-pipelines-drift-detection.yml
name: Drift Detection - $(Date:yyyyMMdd)$(Rev:.r)
trigger: none # No CI/CD trigger
schedules:
- cron: "0 2 * * *" # Daily at 2AM UTC
displayName: Daily Drift Detection
branches:
include:
- main
always: true # Run even if no code changes
variables:
terraformVersion: '1.7.0'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform'
stages:
- stage: DriftDetection
displayName: 'Detect Infrastructure Drift'
jobs:
- job: DetectDrift
displayName: 'Run Terraform Plan and Analyze'
pool:
vmImage: 'ubuntu-latest'
steps:
# Checkout code
- checkout: self
displayName: 'Checkout Terraform Code'
# Install Terraform
- task: TerraformInstaller@1
displayName: 'Install Terraform $(terraformVersion)'
inputs:
terraformVersion: '$(terraformVersion)'
# Azure authentication (read-only SP)
- task: AzureCLI@2
displayName: 'Authenticate to Azure'
inputs:
azureSubscription: 'drift-detection-readonly'
scriptType: 'bash'
scriptLocation: 'inlineScript'
addSpnToEnvironment: true
inlineScript: |
echo "##vso[task.setvariable variable=ARM_CLIENT_ID]$servicePrincipalId"
echo "##vso[task.setvariable variable=ARM_CLIENT_SECRET;issecret=true]$servicePrincipalKey"
echo "##vso[task.setvariable variable=ARM_TENANT_ID]$tenantId"
echo "##vso[task.setvariable variable=ARM_SUBSCRIPTION_ID]$(az account show --query id -o tsv)"
# Terraform init
- script: |
cd $(workingDirectory)
terraform init -backend=true -lock=false
displayName: 'Terraform Init (Read-Only)'
env:
ARM_CLIENT_ID: $(ARM_CLIENT_ID)
ARM_CLIENT_SECRET: $(ARM_CLIENT_SECRET)
ARM_TENANT_ID: $(ARM_TENANT_ID)
ARM_SUBSCRIPTION_ID: $(ARM_SUBSCRIPTION_ID)
# Terraform plan with drift detection
- script: |
cd $(workingDirectory)
# Run terraform plan with detailed exit code
# Exit code 0 = no changes, 1 = error, 2 = changes detected
set +e
terraform plan -detailed-exitcode -out=drift.tfplan -no-color
PLAN_EXIT_CODE=$?
set -e
echo "##vso[task.setvariable variable=PLAN_EXIT_CODE]$PLAN_EXIT_CODE"
if [ $PLAN_EXIT_CODE -eq 0 ]; then
echo "No drift detected"
elif [ $PLAN_EXIT_CODE -eq 2 ]; then
echo "##vso[task.logissue type=warning]Drift detected - changes found"
echo "##vso[task.setvariable variable=DRIFT_DETECTED]true"
else
echo "##vso[task.logissue type=error]Terraform plan failed"
exit 1
fi
displayName: 'Terraform Plan - Drift Detection'
env:
ARM_CLIENT_ID: $(ARM_CLIENT_ID)
ARM_CLIENT_SECRET: $(ARM_CLIENT_SECRET)
ARM_TENANT_ID: $(ARM_TENANT_ID)
ARM_SUBSCRIPTION_ID: $(ARM_SUBSCRIPTION_ID)
# Convert plan to JSON
- script: |
cd $(workingDirectory)
if [ "$(DRIFT_DETECTED)" == "true" ]; then
terraform show -json drift.tfplan > drift.json
echo "Plan converted to JSON for analysis"
fi
displayName: 'Convert Plan to JSON'
condition: eq(variables['DRIFT_DETECTED'], 'true')
# Analyze drift and classify risk
- task: PowerShell@2
displayName: 'Analyze Drift and Classify Risk'
condition: eq(variables['DRIFT_DETECTED'], 'true')
inputs:
targetType: 'inline'
workingDirectory: '$(workingDirectory)'
script: |
$driftJson = Get-Content drift.json | ConvertFrom-Json
$lowRisk = @()
$mediumRisk = @()
$highRisk = @()
$criticalRisk = @()
foreach ($change in $driftJson.resource_changes) {
if ($change.change.actions -contains "no-op") { continue }
$resource = @{
Type = $change.type
Name = $change.name
Address = $change.address
Actions = $change.change.actions -join ", "
}
# High-risk resources (security-sensitive)
if ($change.type -match "azurerm_network_security_|azurerm_role_assignment|azurerm_key_vault|azurerm_firewall") {
$criticalRisk += $resource
}
# Medium-risk resources (scaling, performance)
elseif ($change.type -match "azurerm_virtual_machine|azurerm_app_service|azurerm_kubernetes") {
$mediumRisk += $resource
}
# Low-risk resources (tags, non-functional)
else {
$lowRisk += $resource
}
}
# Generate report
$report = @{
Timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
BuildNumber = "$(Build.BuildNumber)"
TotalChanges = $lowRisk.Count + $mediumRisk.Count + $highRisk.Count + $criticalRisk.Count
LowRisk = $lowRisk
MediumRisk = $mediumRisk
HighRisk = $highRisk
CriticalRisk = $criticalRisk
}
$report | ConvertTo-Json -Depth 10 | Out-File drift-report.json
# Set pipeline variables
Write-Host "##vso[task.setvariable variable=CRITICAL_COUNT]$($criticalRisk.Count)"
Write-Host "##vso[task.setvariable variable=HIGH_COUNT]$($highRisk.Count)"
Write-Host "##vso[task.setvariable variable=MEDIUM_COUNT]$($mediumRisk.Count)"
Write-Host "##vso[task.setvariable variable=LOW_COUNT]$($lowRisk.Count)"
Write-Host "Drift Classification:"
Write-Host " Critical: $($criticalRisk.Count)"
Write-Host " High: $($highRisk.Count)"
Write-Host " Medium: $($mediumRisk.Count)"
Write-Host " Low: $($lowRisk.Count)"
# Publish drift artifacts
- task: PublishPipelineArtifact@1
displayName: 'Publish Drift Artifacts'
condition: eq(variables['DRIFT_DETECTED'], 'true')
inputs:
targetPath: '$(workingDirectory)'
artifact: 'drift-detection-$(Build.BuildNumber)'
publishLocation: 'pipeline'
# Upload to Azure Storage (immutable)
- task: AzureCLI@2
displayName: 'Upload Drift Report to Storage'
condition: eq(variables['DRIFT_DETECTED'], 'true')
inputs:
azureSubscription: 'drift-detection-readonly'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
TIMESTAMP=$(date -u +%Y%m%d-%H%M%S)
CONTAINER="drift-reports"
BLOB_NAME="drift-${TIMESTAMP}-$(Build.BuildNumber).json"
az storage blob upload \
--account-name driftstorageacct \
--container-name $CONTAINER \
--name $BLOB_NAME \
--file $(workingDirectory)/drift-report.json \
--auth-mode login
echo "Drift report uploaded to: $BLOB_NAME"
# Create Azure Monitor alert for critical drift
- task: AzureCLI@2
displayName: 'Create Azure Monitor Alert'
condition: and(succeeded(), gt(variables['CRITICAL_COUNT'], 0))
inputs:
azureSubscription: 'monitoring-service-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
# Log custom metric to Azure Monitor
az monitor metrics emit \
--resource-group rg-monitoring \
--resource tfstate-monitor \
--namespace "Terraform/Drift" \
--metric-name "CriticalDriftDetected" \
--value $(CRITICAL_COUNT) \
--dimension Environment=Production
echo "##vso[task.logissue type=error]Critical drift detected: $(CRITICAL_COUNT) resources"
# Notify team via Teams webhook
- task: PowerShell@2
displayName: 'Notify Teams Channel'
condition: eq(variables['DRIFT_DETECTED'], 'true')
inputs:
targetType: 'inline'
script: |
$webhookUrl = "$(TEAMS_WEBHOOK_URL)"
$color = "0078D4" # Blue
if ([int]$(CRITICAL_COUNT) -gt 0) { $color = "D13438" } # Red
elseif ([int]$(HIGH_COUNT) -gt 0) { $color = "FF8C00" } # Orange
$message = @{
themeColor = $color
title = "Infrastructure Drift Detected"
text = "Environment: Production`nBuild: $(Build.BuildNumber)"
sections = @(
@{
facts = @(
@{ name = "Critical"; value = "$(CRITICAL_COUNT)" }
@{ name = "High"; value = "$(HIGH_COUNT)" }
@{ name = "Medium"; value = "$(MEDIUM_COUNT)" }
@{ name = "Low"; value = "$(LOW_COUNT)" }
)
}
)
potentialAction = @(
@{
"@type" = "OpenUri"
name = "View Pipeline"
targets = @(
@{ os = "default"; uri = "$(System.TeamFoundationCollectionUri)$(System.TeamProject)/_build/results?buildId=$(Build.BuildId)" }
)
}
)
}
Invoke-RestMethod -Uri $webhookUrl -Method Post -Body ($message | ConvertTo-Json -Depth 10) -ContentType "application/json"Drift signals are automatically classified based on resource type, change type, and impact scope.
Risk Classification Matrix:
| Resource Type | Change Type | Risk Level | Approval Required | Response Time |
|---|---|---|---|---|
| NSG Rules | Create/Delete | Critical | VP + Security | Immediate |
| Role Assignments | Any | Critical | Security Team | < 2 hours |
| Key Vault Policies | Any | Critical | Security + Compliance | < 4 hours |
| Firewall Rules | Any | Critical | Network + Security | Immediate |
| VM SKU Changes | Update | Medium | Team Lead | < 24 hours |
| App Service Plans | Scale | Medium | Team Lead | < 24 hours |
| Storage Account | Configuration | Medium | Ops Team | < 48 hours |
| Resource Tags | Any | Low | Batch Review | Weekly |
| Resource Descriptions | Any | Low | Batch Review | Weekly |
# PowerShell - Comprehensive drift analysis
function Analyze-TerraformDrift {
param(
[Parameter(Mandatory=$true)]
[string]$DriftJsonPath
)
$drift = Get-Content $DriftJsonPath | ConvertFrom-Json
# Initialize counters
$analysis = @{
TotalResources = 0
ChangedResources = 0
CreatedResources = 0
UpdatedResources = 0
DeletedResources = 0
ReplacedResources = 0
RiskBreakdown = @{
Critical = @()
High = @()
Medium = @()
Low = @()
}
}
# High-risk resource patterns
$criticalPatterns = @(
'azurerm_network_security_group',
'azurerm_network_security_rule',
'azurerm_role_assignment',
'azurerm_role_definition',
'azurerm_key_vault_access_policy',
'azurerm_firewall',
'azurerm_firewall_policy',
'azurerm_private_endpoint'
)
$mediumPatterns = @(
'azurerm_virtual_machine',
'azurerm_linux_virtual_machine',
'azurerm_windows_virtual_machine',
'azurerm_kubernetes_cluster',
'azurerm_app_service',
'azurerm_function_app',
'azurerm_sql_database'
)
foreach ($change in $drift.resource_changes) {
$actions = $change.change.actions
# Skip no-op changes
if ($actions -contains 'no-op') { continue }
$analysis.TotalResources++
# Count by action type
if ($actions -contains 'create') { $analysis.CreatedResources++ }
if ($actions -contains 'update') { $analysis.UpdatedResources++ }
if ($actions -contains 'delete') { $analysis.DeletedResources++ }
if ($actions -contains 'create' -and $actions -contains 'delete') {
$analysis.ReplacedResources++
}
# Build change object
$changeDetail = @{
Address = $change.address
Type = $change.type
Name = $change.name
Actions = $actions -join ', '
Before = $change.change.before
After = $change.change.after
}
# Classify risk
$isCritical = $criticalPatterns | Where-Object { $change.type -match $_ }
$isMedium = $mediumPatterns | Where-Object { $change.type -match $_ }
if ($isCritical) {
$analysis.RiskBreakdown.Critical += $changeDetail
}
elseif ($isMedium) {
$analysis.RiskBreakdown.Medium += $changeDetail
}
else {
$analysis.RiskBreakdown.Low += $changeDetail
}
}
# Generate summary
Write-Host "=== Drift Analysis Summary ===" -ForegroundColor Cyan
Write-Host "Total Resources Changed: $($analysis.TotalResources)"
Write-Host " Created: $($analysis.CreatedResources)"
Write-Host " Updated: $($analysis.UpdatedResources)"
Write-Host " Deleted: $($analysis.DeletedResources)"
Write-Host " Replaced: $($analysis.ReplacedResources)"
Write-Host ""
Write-Host "Risk Breakdown:" -ForegroundColor Yellow
Write-Host " Critical: $($analysis.RiskBreakdown.Critical.Count)" -ForegroundColor Red
Write-Host " High: $($analysis.RiskBreakdown.High.Count)" -ForegroundColor DarkYellow
Write-Host " Medium: $($analysis.RiskBreakdown.Medium.Count)" -ForegroundColor Yellow
Write-Host " Low: $($analysis.RiskBreakdown.Low.Count)" -ForegroundColor Green
return $analysis
}
# Usage
$analysis = Analyze-TerraformDrift -DriftJsonPath "drift.json"
$analysis | ConvertTo-Json -Depth 10 | Out-File "drift-analysis.json"#!/usr/bin/env python3
"""
Terraform Drift Analysis Tool
Parses terraform plan JSON output and classifies drift by risk level
"""
import json
import sys
from datetime import datetime
from typing import Dict, List, Any
class DriftAnalyzer:
def __init__(self, plan_json_path: str):
with open(plan_json_path, 'r') as f:
self.plan = json.load(f)
self.critical_resources = [
'azurerm_network_security_group',
'azurerm_network_security_rule',
'azurerm_role_assignment',
'azurerm_key_vault_access_policy',
'azurerm_firewall',
'azurerm_private_endpoint'
]
self.high_resources = [
'azurerm_virtual_network',
'azurerm_subnet',
'azurerm_storage_account',
'azurerm_key_vault'
]
self.medium_resources = [
'azurerm_virtual_machine',
'azurerm_app_service',
'azurerm_kubernetes_cluster',
'azurerm_sql_database'
]
def analyze(self) -> Dict[str, Any]:
"""Analyze drift and classify by risk level"""
results = {
'timestamp': datetime.utcnow().isoformat(),
'total_changes': 0,
'critical': [],
'high': [],
'medium': [],
'low': [],
'statistics': {
'creates': 0,
'updates': 0,
'deletes': 0,
'replaces': 0
}
}
resource_changes = self.plan.get('resource_changes', [])
for change in resource_changes:
actions = change['change']['actions']
# Skip no-op
if actions == ['no-op']:
continue
results['total_changes'] += 1
# Count action types
if 'create' in actions:
results['statistics']['creates'] += 1
if 'update' in actions:
results['statistics']['updates'] += 1
if 'delete' in actions:
results['statistics']['deletes'] += 1
if 'create' in actions and 'delete' in actions:
results['statistics']['replaces'] += 1
# Build change detail
detail = {
'address': change['address'],
'type': change['type'],
'name': change['name'],
'actions': actions,
'provider': change.get('provider_name', 'unknown')
}
# Classify risk
resource_type = change['type']
if any(pattern in resource_type for pattern in self.critical_resources):
results['critical'].append(detail)
elif any(pattern in resource_type for pattern in self.high_resources):
results['high'].append(detail)
elif any(pattern in resource_type for pattern in self.medium_resources):
results['medium'].append(detail)
else:
results['low'].append(detail)
return results
def generate_report(self) -> str:
"""Generate human-readable report"""
analysis = self.analyze()
report = []
report.append("=" * 60)
report.append("TERRAFORM DRIFT DETECTION REPORT")
report.append("=" * 60)
report.append(f"Timestamp: {analysis['timestamp']}")
report.append(f"Total Changes Detected: {analysis['total_changes']}")
report.append("")
report.append("STATISTICS:")
report.append(f" Creates: {analysis['statistics']['creates']}")
report.append(f" Updates: {analysis['statistics']['updates']}")
report.append(f" Deletes: {analysis['statistics']['deletes']}")
report.append(f" Replaces: {analysis['statistics']['replaces']}")
report.append("")
report.append("RISK CLASSIFICATION:")
report.append(f" 🔴 Critical: {len(analysis['critical'])}")
report.append(f" 🟠 High: {len(analysis['high'])}")
report.append(f" 🟡 Medium: {len(analysis['medium'])}")
report.append(f" 🟢 Low: {len(analysis['low'])}")
report.append("")
# Critical details
if analysis['critical']:
report.append("CRITICAL DRIFT (Immediate Action Required):")
report.append("-" * 60)
for item in analysis['critical']:
report.append(f" • {item['address']}")
report.append(f" Type: {item['type']}")
report.append(f" Actions: {', '.join(item['actions'])}")
report.append("")
# High details
if analysis['high']:
report.append("HIGH RISK DRIFT (Review Required):")
report.append("-" * 60)
for item in analysis['high']:
report.append(f" • {item['address']}")
report.append(f" Actions: {', '.join(item['actions'])}")
report.append("")
report.append("=" * 60)
return "\n".join(report)
# Usage
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: drift_analyzer.py <plan.json>")
sys.exit(1)
analyzer = DriftAnalyzer(sys.argv[1])
# Generate report
report = analyzer.generate_report()
print(report)
# Save JSON analysis
analysis = analyzer.analyze()
with open('drift-analysis.json', 'w') as f:
json.dump(analysis, f, indent=2)
# Exit with error code if critical drift detected
if analysis['critical']:
sys.exit(2)
elif analysis['high']:
sys.exit(1)
else:
sys.exit(0)Core Principle: All drift remediation must flow through Terraform code changes and standard CI/CD pipelines. Direct Portal corrections are prohibited.
sequenceDiagram
participant Detection as Drift Detection
participant Engineer as Platform Engineer
participant Git as Git Repository
participant Pipeline as Remediation Pipeline
participant Azure as Azure Resources
Detection->>Engineer: Drift Report (Teams/Email)
Engineer->>Git: Create Branch
Engineer->>Git: Update Terraform Code
Engineer->>Git: Create Pull Request
Git->>Pipeline: Trigger Plan
Pipeline->>Engineer: Plan Review
Engineer->>Git: Approve PR
Git->>Pipeline: Trigger Apply (with Approval)
Pipeline->>Azure: Terraform Apply
Azure->>Detection: Verify Drift Resolved
# azure-pipelines-drift-remediation.yml
name: Drift Remediation - $(Date:yyyyMMdd)$(Rev:.r)
trigger:
branches:
include:
- main
paths:
include:
- terraform/**
variables:
terraformVersion: '1.7.0'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform'
stages:
- stage: Plan
displayName: 'Terraform Plan'
jobs:
- job: TerraformPlan
pool:
vmImage: 'ubuntu-latest'
steps:
- checkout: self
- task: TerraformInstaller@1
inputs:
terraformVersion: '$(terraformVersion)'
- task: AzureCLI@2
displayName: 'Terraform Init & Plan'
inputs:
azureSubscription: 'terraform-service-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
addSpnToEnvironment: true
inlineScript: |
cd $(workingDirectory)
export ARM_CLIENT_ID=$servicePrincipalId
export ARM_CLIENT_SECRET=$servicePrincipalKey
export ARM_TENANT_ID=$tenantId
export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)
terraform init
terraform plan -out=remediation.tfplan
terraform show -json remediation.tfplan > remediation.json
- task: PublishPipelineArtifact@1
inputs:
targetPath: '$(workingDirectory)'
artifact: 'terraform-plan'
- stage: ApproveRemediation
displayName: 'Approve Drift Remediation'
dependsOn: Plan
jobs:
- deployment: ApproveRemediationJob
displayName: 'Manual Approval Gate'
environment: 'production-drift-remediation'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: 'terraform-plan'
- task: PowerShell@2
displayName: 'Display Remediation Plan'
inputs:
targetType: 'inline'
script: |
Write-Host "=== DRIFT REMEDIATION PLAN ===" -ForegroundColor Cyan
Write-Host ""
Write-Host "Review the terraform plan artifact before approving."
Write-Host ""
Write-Host "Approval checklist:" -ForegroundColor Yellow
Write-Host " [ ] Plan reviewed and understood"
Write-Host " [ ] Changes align with drift report"
Write-Host " [ ] No unintended resource deletions"
Write-Host " [ ] Rollback plan documented"
Write-Host " [ ] Change ticket created: ___________"
- stage: Apply
displayName: 'Apply Remediation'
dependsOn: ApproveRemediation
condition: succeeded()
jobs:
- job: TerraformApply
pool:
vmImage: 'ubuntu-latest'
steps:
- download: current
artifact: 'terraform-plan'
- task: TerraformInstaller@1
inputs:
terraformVersion: '$(terraformVersion)'
- task: AzureCLI@2
displayName: 'Terraform Apply'
inputs:
azureSubscription: 'terraform-service-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
addSpnToEnvironment: true
inlineScript: |
cd $(Pipeline.Workspace)/terraform-plan
export ARM_CLIENT_ID=$servicePrincipalId
export ARM_CLIENT_SECRET=$servicePrincipalKey
export ARM_TENANT_ID=$tenantId
export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)
terraform init
terraform apply -auto-approve remediation.tfplan
echo "Drift remediation completed successfully"
- task: AzureCLI@2
displayName: 'Log Remediation to Audit Storage'
inputs:
azureSubscription: 'monitoring-service-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
TIMESTAMP=$(date -u +%Y%m%d-%H%M%S)
# Create audit record
cat > remediation-audit.json << EOF
{
"timestamp": "$TIMESTAMP",
"buildNumber": "$(Build.BuildNumber)",
"approver": "$(Build.RequestedFor)",
"type": "drift-remediation",
"status": "completed"
}
EOF
# Upload to audit storage
az storage blob upload \
--account-name auditstorageacct \
--container-name remediation-logs \
--name "remediation-${TIMESTAMP}.json" \
--file remediation-audit.json \
--auth-mode loginFor critical security incidents requiring immediate correction:
# Emergency remediation with elevated approval
parameters:
- name: emergencyRemediation
displayName: 'Emergency Remediation (requires VP approval)'
type: boolean
default: false
- name: incidentTicket
displayName: 'Incident Ticket Number'
type: string
default: ''
stages:
- stage: EmergencyApproval
condition: eq('${{ parameters.emergencyRemediation }}', true)
jobs:
- deployment: EmergencyApprove
environment: 'production-emergency'
strategy:
runOnce:
preDeploy:
steps:
- script: |
if [ -z "${{ parameters.incidentTicket }}" ]; then
echo "##vso[task.logissue type=error]Incident ticket required for emergency remediation"
exit 1
fi
echo "Emergency remediation authorized"
echo "Incident: ${{ parameters.incidentTicket }}"
echo "##vso[build.addbuildtag]emergency-remediation"
deploy:
steps:
- task: TerraformInstaller@1
inputs:
terraformVersion: '1.7.0'
- task: AzureCLI@2
displayName: 'Emergency Terraform Apply'
inputs:
azureSubscription: 'terraform-service-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
addSpnToEnvironment: true
inlineScript: |
export ARM_CLIENT_ID=$servicePrincipalId
export ARM_CLIENT_SECRET=$servicePrincipalKey
export ARM_TENANT_ID=$tenantId
export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)
terraform init
terraform apply -auto-approve
postRouting:
steps:
- task: AzureCLI@2
displayName: 'Schedule Post-Remediation Review'
inputs:
azureSubscription: 'monitoring-service-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
# Create follow-up work item
az boards work-item create \
--type "Task" \
--title "Post-Emergency Remediation Review - ${{ parameters.incidentTicket }}" \
--description "Review emergency drift remediation and update procedures" \
--assigned-to "ops-manager@company.com"# Terraform backend with state locking for drift detection
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "tfstatestorage"
container_name = "tfstate"
key = "production.terraform.tfstate"
# State locking prevents concurrent modifications
use_azuread_auth = true
# Snapshot isolation for drift detection
snapshot = false
}
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.80"
}
}
}
# Storage account for Terraform state
resource "azurerm_storage_account" "tfstate" {
name = "tfstatestorage"
resource_group_name = azurerm_resource_group.tfstate.name
location = "eastus2"
account_tier = "Standard"
account_replication_type = "GRS"
# Versioning for state history
blob_properties {
versioning_enabled = true
delete_retention_policy {
days = 30
}
}
# Immutable storage for compliance
immutability_policy {
allow_protected_append_writes = true
state = "Unlocked"
period_since_creation_in_days = 7
}
# Network isolation
network_rules {
default_action = "Deny"
ip_rules = ["40.71.11.0/24"] # Azure DevOps IP range
bypass = ["AzureServices"]
}
tags = {
Purpose = "Terraform State Backend"
ManagedBy = "Terraform"
}
}
# Separate storage for drift reports
resource "azurerm_storage_account" "drift_reports" {
name = "driftstorageacct"
resource_group_name = azurerm_resource_group.tfstate.name
location = "eastus2"
account_tier = "Standard"
account_replication_type = "GRS"
blob_properties {
versioning_enabled = true
# Immutable blobs prevent tampering
immutability_policy {
allow_protected_append_writes = false
state = "Locked"
period_since_creation_in_days = 365 # 1 year retention
}
}
tags = {
Purpose = "Drift Detection Reports"
Compliance = "Immutable Audit Trail"
}
}
# Container for drift reports
resource "azurerm_storage_container" "drift_reports" {
name = "drift-reports"
storage_account_name = azurerm_storage_account.drift_reports.name
container_access_type = "private"
}# Terraform - Azure Monitor workspace for drift metrics
resource "azurerm_log_analytics_workspace" "drift_monitoring" {
name = "law-drift-monitoring"
location = "eastus2"
resource_group_name = azurerm_resource_group.monitoring.name
sku = "PerGB2018"
retention_in_days = 90
}
# Application Insights for custom metrics
resource "azurerm_application_insights" "drift_metrics" {
name = "appi-drift-metrics"
location = "eastus2"
resource_group_name = azurerm_resource_group.monitoring.name
workspace_id = azurerm_log_analytics_workspace.drift_monitoring.id
application_type = "other"
}
# Alert rule for critical drift
resource "azurerm_monitor_scheduled_query_rules_alert_v2" "critical_drift" {
name = "alert-critical-drift-detected"
resource_group_name = azurerm_resource_group.monitoring.name
location = "eastus2"
evaluation_frequency = "PT15M"
window_duration = "PT1H"
scopes = [azurerm_log_analytics_workspace.drift_monitoring.id]
severity = 1 # Critical
criteria {
query = <<-QUERY
customMetrics
| where name == "CriticalDriftDetected"
| where timestamp > ago(1h)
| summarize DriftCount = sum(value) by Environment = tostring(customDimensions.Environment)
| where DriftCount > 0
QUERY
time_aggregation_method = "Count"
threshold = 1
operator = "GreaterThan"
failing_periods {
minimum_failing_periods_to_trigger_alert = 1
number_of_evaluation_periods = 1
}
}
action {
action_groups = [
azurerm_monitor_action_group.security_team.id,
azurerm_monitor_action_group.ops_team.id
]
}
description = "Critical infrastructure drift detected in production environment"
}
# Action group for security team
resource "azurerm_monitor_action_group" "security_team" {
name = "ag-security-drift"
resource_group_name = azurerm_resource_group.monitoring.name
short_name = "secu-drift"
email_receiver {
name = "Security Team"
email_address = "security@company.com"
}
sms_receiver {
name = "On-Call Security"
country_code = "1"
phone_number = "5551234567"
}
webhook_receiver {
name = "Teams Webhook"
service_uri = var.teams_webhook_url
}
}// KQL - Query drift detection metrics
customMetrics
| where name in ("CriticalDriftDetected", "HighDriftDetected", "MediumDriftDetected")
| where timestamp > ago(30d)
| extend Environment = tostring(customDimensions.Environment)
| summarize
TotalDriftIncidents = count(),
CriticalCount = sumif(value, name == "CriticalDriftDetected"),
HighCount = sumif(value, name == "HighDriftDetected"),
MediumCount = sumif(value, name == "MediumDriftDetected")
by bin(timestamp, 1d), Environment
| order by timestamp desc
// Drift detection pipeline success rate
traces
| where message contains "Drift Detection"
| where timestamp > ago(30d)
| extend Status = case(
message contains "No drift detected", "Success",
message contains "Drift detected", "DriftFound",
message contains "failed", "Failed",
"Unknown"
)
| summarize
TotalRuns = count(),
DriftDetected = countif(Status == "DriftFound"),
Failed = countif(Status == "Failed")
by bin(timestamp, 1d)
| extend SuccessRate = (TotalRuns - Failed) * 100.0 / TotalRuns
| project timestamp, TotalRuns, DriftDetected, Failed, SuccessRate
| order by timestamp desc
// Most frequently drifting resource types
customEvents
| where name == "DriftAnalysis"
| extend ResourceType = tostring(customDimensions.ResourceType)
| extend RiskLevel = tostring(customDimensions.RiskLevel)
| summarize DriftCount = count() by ResourceType, RiskLevel
| order by DriftCount desc
| take 20All drift detection and remediation activities must be logged to immutable storage for compliance and forensic analysis.
Audit Log Components:
| Component | Data Captured | Retention | Purpose |
|---|---|---|---|
| Detection Logs | Timestamp, drift details, classification | 2 years | Compliance reporting |
| Plan Artifacts | Terraform plan JSON, analysis results | 1 year | Forensic investigation |
| Remediation Logs | Approver, changes applied, timestamp | 2 years | Change control audit |
| Access Logs | Service principal usage, API calls | 90 days | Security monitoring |
# PowerShell - Generate monthly drift compliance report
param(
[Parameter(Mandatory=$true)]
[string]$Month, # Format: 2026-01
[Parameter(Mandatory=$true)]
[string]$WorkspaceId,
[Parameter(Mandatory=$true)]
[string]$OutputPath
)
# Query Log Analytics for drift data
$query = @"
union customMetrics, traces
| where timestamp >= datetime($Month-01) and timestamp < datetime_add('month', 1, datetime($Month-01))
| where name contains "Drift" or message contains "Drift"
| extend EventType = case(
name == "CriticalDriftDetected", "Critical",
name == "HighDriftDetected", "High",
name == "MediumDriftDetected", "Medium",
name == "LowDriftDetected", "Low",
"Detection"
)
| summarize
TotalDetectionRuns = countif(message contains "Drift Detection"),
TotalDriftIncidents = countif(EventType != "Detection"),
CriticalIncidents = countif(EventType == "Critical"),
HighIncidents = countif(EventType == "High"),
MediumIncidents = countif(EventType == "Medium"),
LowIncidents = countif(EventType == "Low"),
AvgDetectionTime = avgif(duration, message contains "completed")
| extend ComplianceRate = (TotalDetectionRuns - TotalDriftIncidents) * 100.0 / TotalDetectionRuns
"@
$result = az monitor log-analytics query `
--workspace $WorkspaceId `
--analytics-query $query `
--output json | ConvertFrom-Json
# Generate HTML report
$html = @"
<!DOCTYPE html>
<html>
<head>
<title>Drift Detection Compliance Report - $Month</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #0078D4; }
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background-color: #0078D4; color: white; }
.critical { color: #D13438; font-weight: bold; }
.high { color: #FF8C00; font-weight: bold; }
.metric { font-size: 24px; font-weight: bold; color: #0078D4; }
</style>
</head>
<body>
<h1>Infrastructure Drift Compliance Report</h1>
<p><strong>Period:</strong> $Month</p>
<p><strong>Generated:</strong> $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss UTC')</p>
<h2>Summary Metrics</h2>
<table>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
<tr>
<td>Total Detection Runs</td>
<td class="metric">$($result.TotalDetectionRuns)</td>
</tr>
<tr>
<td>Total Drift Incidents</td>
<td class="metric">$($result.TotalDriftIncidents)</td>
</tr>
<tr>
<td class="critical">Critical Incidents</td>
<td class="metric critical">$($result.CriticalIncidents)</td>
</tr>
<tr>
<td class="high">High Risk Incidents</td>
<td class="metric high">$($result.HighIncidents)</td>
</tr>
<tr>
<td>Compliance Rate</td>
<td class="metric">$([math]::Round($result.ComplianceRate, 2))%</td>
</tr>
</table>
<h2>Compliance Assessment</h2>
<p>
$(if ($result.ComplianceRate -gt 95) {
"✅ <strong>Excellent</strong> - Infrastructure drift is well-controlled"
} elseif ($result.ComplianceRate -gt 90) {
"⚠️ <strong>Good</strong> - Minor drift detected, review recommended"
} else {
"❌ <strong>Action Required</strong> - Significant drift detected, immediate review required"
})
</p>
</body>
</html>
"@
$html | Out-File -FilePath $OutputPath -Encoding UTF8
Write-Host "Compliance report generated: $OutputPath"Playbook: Critical Drift Detected
Trigger: Azure Monitor alert for critical drift (NSG, IAM, encryption changes)
Response Steps:
-
Immediate Assessment (< 15 minutes)
# Retrieve drift report az storage blob download \ --account-name driftstorageacct \ --container-name drift-reports \ --name <drift-report-blob> \ --file drift-report.json # Analyze critical changes cat drift-report.json | jq '.CriticalRisk[]'
-
Security Review (< 30 minutes)
- Review Azure Activity Log for unauthorized changes
- Identify who made the change (user/service principal)
- Determine if change was authorized (emergency break-glass)
-
Risk Assessment (< 1 hour)
- Classify intent: malicious, accidental, or emergency
- Assess blast radius (affected resources, environments)
- Determine rollback vs fix-forward approach
-
Remediation Decision (< 2 hours)
- Unauthorized: Revert immediately via emergency pipeline
- Authorized Emergency: Document in change ticket, schedule remediation
- Accidental: Create remediation PR with standard approval
-
Post-Incident Actions
- Update RBAC to prevent recurrence
- Document lessons learned
- Update drift detection rules if needed
#!/bin/bash
# Drift incident response toolkit
DRIFT_REPORT=$1
if [ -z "$DRIFT_REPORT" ]; then
echo "Usage: drift-response.sh <drift-report.json>"
exit 1
fi
echo "=== Drift Incident Response ==="
echo ""
# Parse critical resources
CRITICAL_COUNT=$(jq '.CriticalRisk | length' $DRIFT_REPORT)
if [ $CRITICAL_COUNT -gt 0 ]; then
echo "⚠️ CRITICAL DRIFT DETECTED: $CRITICAL_COUNT resources"
echo ""
echo "Affected Resources:"
jq -r '.CriticalRisk[] | " - \(.Address) (\(.Type))"' $DRIFT_REPORT
echo ""
# Check Azure Activity Log for recent changes
echo "Retrieving Azure Activity Log..."
az monitor activity-log list \
--start-time $(date -u -d '1 hour ago' '+%Y-%m-%dT%H:%M:%SZ') \
--query "[?level=='Critical' || level=='Error'].{Time:eventTimestamp, Caller:caller, Operation:operationName.localizedValue}" \
--output table
echo ""
echo "Next Steps:"
echo "1. Review Activity Log for unauthorized changes"
echo "2. Determine if change was emergency break-glass"
echo "3. Create incident ticket"
echo "4. Initiate emergency remediation if necessary"
else
echo "✅ No critical drift detected"
fi| Anti-Pattern | Description | Impact | Correct Approach |
|---|---|---|---|
| Auto-Apply Drift Corrections | Automatically applying terraform changes without human approval | Unreviewed changes to production, potential outages | Always require manual approval for production |
| Ignoring Persistent Drift | Not investigating resources that drift repeatedly | Root cause remains unfixed, recurring incidents | Track drift frequency, investigate patterns |
| Manual State Edits | Using terraform state rm or manual state manipulation |
State corruption, lost tracking | Use proper Terraform import/remove workflows |
| Disabling Detection Pipelines | Turning off drift detection to reduce noise | Blind to infrastructure changes, compliance violations | Tune classification rules, don't disable detection |
| Portal-Based Remediation | Fixing drift directly in Azure Portal | Terraform state divergence, undocumented changes | Always remediate through Terraform code |
| Shared Detection/Remediation Identity | Same service principal for detection and remediation | Privilege escalation risk, no separation of duties | Use separate SPs with minimal permissions |
| No Drift Baseline | Not establishing expected drift patterns | False positives overwhelm team | Document expected drift (auto-scaling, platform updates) |
| Batch Remediations Without Review | Applying multiple drift fixes without individual review | Compound errors, difficult rollback | Review and remediate drift incrementally |
// KQL - Detect anti-pattern: Same identity for detection and remediation
AzureActivity
| where TimeGenerated > ago(7d)
| where OperationNameValue contains "terraform"
| extend Action = case(
ActivityStatus == "Succeeded" and OperationNameValue contains "write", "Remediation",
ActivityStatus == "Succeeded" and OperationNameValue contains "read", "Detection",
"Unknown"
)
| where Action in ("Detection", "Remediation")
| summarize
DetectionCount = countif(Action == "Detection"),
RemediationCount = countif(Action == "Remediation")
by Caller
| where DetectionCount > 0 and RemediationCount > 0
| project Caller, Issue = "Same identity used for detection and remediation"
// Anti-pattern: Frequent drift on same resources
customEvents
| where name == "DriftDetected"
| extend ResourceAddress = tostring(customDimensions.ResourceAddress)
| where timestamp > ago(30d)
| summarize DriftCount = count() by ResourceAddress
| where DriftCount > 5
| order by DriftCount desc
| project ResourceAddress, DriftCount, Issue = "Recurring drift - investigate root cause"# .github/workflows/drift-detection.yml
name: Terraform Drift Detection
on:
schedule:
- cron: '0 2 * * *' # Daily at 2 AM UTC
workflow_dispatch: # Manual trigger
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
TERRAFORM_VERSION: '1.7.0'
jobs:
detect-drift:
name: 'Detect Infrastructure Drift'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # For OIDC authentication
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TERRAFORM_VERSION }}
- name: Azure Login (OIDC)
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Terraform Init
working-directory: ./terraform
run: |
terraform init -backend=true -lock=false
- name: Terraform Plan (Drift Detection)
id: plan
working-directory: ./terraform
run: |
set +e
terraform plan -detailed-exitcode -out=drift.tfplan -no-color
EXIT_CODE=$?
set -e
echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
if [ $EXIT_CODE -eq 0 ]; then
echo "✅ No drift detected"
elif [ $EXIT_CODE -eq 2 ]; then
echo "⚠️ Drift detected - changes found"
echo "drift_detected=true" >> $GITHUB_OUTPUT
else
echo "❌ Terraform plan failed"
exit 1
fi
- name: Convert Plan to JSON
if: steps.plan.outputs.drift_detected == 'true'
working-directory: ./terraform
run: |
terraform show -json drift.tfplan > drift.json
- name: Analyze Drift with Python
if: steps.plan.outputs.drift_detected == 'true'
working-directory: ./terraform
run: |
python3 ../scripts/drift_analyzer.py drift.json > drift-report.txt
# Capture exit code for risk level
EXIT_CODE=$?
echo "risk_level=$EXIT_CODE" >> $GITHUB_OUTPUT
id: analyze
- name: Upload Drift Artifacts
if: steps.plan.outputs.drift_detected == 'true'
uses: actions/upload-artifact@v4
with:
name: drift-detection-${{ github.run_number }}
path: |
terraform/drift.tfplan
terraform/drift.json
terraform/drift-report.txt
retention-days: 90
- name: Upload to Azure Storage
if: steps.plan.outputs.drift_detected == 'true'
run: |
TIMESTAMP=$(date -u +%Y%m%d-%H%M%S)
BLOB_NAME="drift-${TIMESTAMP}-${{ github.run_number }}.json"
az storage blob upload \
--account-name driftstorageacct \
--container-name drift-reports \
--name $BLOB_NAME \
--file terraform/drift.json \
--auth-mode login
- name: Create Issue for Critical Drift
if: steps.analyze.outputs.risk_level == '2'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const report = fs.readFileSync('terraform/drift-report.txt', 'utf8');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `🔴 Critical Infrastructure Drift Detected - Run #${context.runNumber}`,
body: `## Critical Drift Detected\n\n` +
`**Workflow Run**: [#${context.runNumber}](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})\n\n` +
`**Drift Report:**\n\`\`\`\n${report}\n\`\`\`\n\n` +
`**Action Required**: Review drift report and create remediation PR.`,
labels: ['infrastructure', 'drift', 'critical']
});
- name: Send Teams Notification
if: steps.plan.outputs.drift_detected == 'true'
run: |
RISK_LEVEL="${{ steps.analyze.outputs.risk_level }}"
if [ "$RISK_LEVEL" == "2" ]; then
COLOR="D13438" # Red
TITLE="🔴 Critical Infrastructure Drift"
elif [ "$RISK_LEVEL" == "1" ]; then
COLOR="FF8C00" # Orange
TITLE="🟠 High Risk Infrastructure Drift"
else
COLOR="0078D4" # Blue
TITLE="ℹ️ Infrastructure Drift Detected"
fi
curl -X POST "${{ secrets.TEAMS_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d "{
\"themeColor\": \"$COLOR\",
\"title\": \"$TITLE\",
\"text\": \"Workflow Run: #${{ github.run_number }}\",
\"sections\": [{
\"activityTitle\": \"Drift Detection Results\",
\"facts\": [{
\"name\": \"Repository\",
\"value\": \"${{ github.repository }}\"
}, {
\"name\": \"Branch\",
\"value\": \"${{ github.ref_name }}\"
}]
}],
\"potentialAction\": [{
\"@type\": \"OpenUri\",
\"name\": \"View Workflow Run\",
\"targets\": [{
\"os\": \"default\",
\"uri\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"
}]
}]
}"
remediate-drift:
name: 'Remediate Drift (Manual Approval)'
needs: detect-drift
if: needs.detect-drift.outputs.drift_detected == 'true'
runs-on: ubuntu-latest
environment:
name: production-drift-remediation
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Download Drift Plan
uses: actions/download-artifact@v4
with:
name: drift-detection-${{ github.run_number }}
path: terraform
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TERRAFORM_VERSION }}
- name: Azure Login
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Terraform Apply
working-directory: ./terraform
run: |
terraform init
terraform apply -auto-approve drift.tfplan
echo "✅ Drift remediation completed"- Terraform on Azure
- Azure Storage - Immutable Blob Storage
- Azure Monitor - Custom Metrics
- Azure DevOps Pipelines - Scheduled Triggers
- Azure RBAC for Service Principals
- Terraform Plan - Detailed Exit Code
- Terraform State Locking
- Terraform Backend Configuration
- Terraform JSON Output Format
- NIST 800-171 R2 - Configuration Management
- SOC 2 Trust Service Criteria - Change Management
- ISO 27001:2013 - Control A.12.1.2
Pre-Implementation Checklist:
- Service principal created with Reader permissions
- Separate service principal for remediation with Contributor permissions
- Azure Storage account configured for drift reports (immutable blobs)
- Azure Storage account configured for Terraform state (versioning enabled)
- Log Analytics workspace created for drift metrics
- Azure Monitor alert rules configured
- Teams webhook configured for notifications
- Scheduled pipeline trigger configured (daily)
- Python/PowerShell analysis scripts tested
- Drift classification rules defined
- Approval environment configured in Azure DevOps
- Documentation updated with drift response procedures
| Azure Resource Type | Default Risk Level | Rationale |
|---|---|---|
azurerm_network_security_group |
Critical | Security boundary control |
azurerm_network_security_rule |
Critical | Firewall rule changes |
azurerm_role_assignment |
Critical | Access control |
azurerm_role_definition |
Critical | Permission definitions |
azurerm_key_vault_access_policy |
Critical | Secret access control |
azurerm_firewall |
Critical | Network security |
azurerm_firewall_policy |
Critical | Firewall configuration |
azurerm_private_endpoint |
Critical | Network isolation |
azurerm_virtual_network |
High | Network topology |
azurerm_subnet |
High | Network segmentation |
azurerm_storage_account |
High | Data storage configuration |
azurerm_key_vault |
High | Secrets management |
azurerm_virtual_machine |
Medium | Compute resources |
azurerm_linux_virtual_machine |
Medium | Compute resources |
azurerm_windows_virtual_machine |
Medium | Compute resources |
azurerm_kubernetes_cluster |
Medium | Container orchestration |
azurerm_app_service |
Medium | Application hosting |
azurerm_function_app |
Medium | Serverless compute |
azurerm_sql_database |
Medium | Database configuration |
azurerm_resource_group |
Low | Logical container |
| Tags (all resources) | Low | Metadata |
| Descriptions | Low | Documentation |
Scenario 1: Developer Adds NSG Rule via Portal
Detection: Critical drift detected - NSG rule added
Root Cause: Developer troubleshooting connectivity issue
Response:
1. Immediate assessment - verify rule is not malicious
2. Document rule in Terraform code
3. Create PR with rule definition
4. Obtain approval from security team
5. Apply via pipeline
6. RBAC review to prevent portal access
Time to Remediation: 2-4 hours
Scenario 2: Auto-Scaling Event
Detection: Medium drift detected - VM count changed
Root Cause: VMSS auto-scaled based on CPU metrics
Response:
1. Verify auto-scaling is configured correctly
2. Update Terraform to ignore 'instances' attribute
3. Document expected drift pattern
4. No remediation required
Time to Remediation: N/A (expected drift)
Scenario 3: Azure Platform Update
Detection: Low drift detected - default tags added
Root Cause: Azure added platform-managed tags
Response:
1. Verify tags are Azure-managed (e.g., ms-resource-usage)
2. Update Terraform to ignore platform tags
3. Use lifecycle ignore_changes block
4. Document in drift baseline
Time to Remediation: Documentation only
Scenario 4: Emergency Break-Glass Change
Detection: Critical drift detected - firewall rule modified
Root Cause: Security incident response - blocking malicious IP
Response:
1. Validate incident ticket exists
2. Confirm change was authorized
3. Schedule remediation after incident closure
4. Update Terraform with permanent rule if needed
5. Document in change management system
Time to Remediation: Scheduled (post-incident)
# Ignore Azure-managed tags
resource "azurerm_resource_group" "example" {
name = "rg-example"
location = "eastus2"
lifecycle {
ignore_changes = [
tags["ms-resource-usage"],
tags["hidden-link"],
]
}
}
# Ignore auto-scaling changes
resource "azurerm_virtual_machine_scale_set" "example" {
name = "vmss-example"
resource_group_name = azurerm_resource_group.example.name
location = azurerm_resource_group.example.location
sku = "Standard_DS1_v2"
lifecycle {
ignore_changes = [
instances # Auto-scaling will modify this
]
}
}
# Ignore Azure Backup modifications
resource "azurerm_recovery_services_vault" "example" {
name = "rsv-example"
resource_group_name = azurerm_resource_group.example.name
location = azurerm_resource_group.example.location
sku = "Standard"
lifecycle {
ignore_changes = [
tags["AzureBackup"], # Azure Backup adds tags
]
}
}Document Version: 1.0
Last Updated: January 2026
Maintained By: Randy Bordeaux
Review Cycle: Quarterly