Coverage for lambda / drift-detection / handler.py: 100.00%

63 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1""" 

2CloudFormation Drift Detection Lambda Handler. 

3 

4This Lambda is invoked on a schedule (default: daily) by an EventBridge rule. 

5It initiates drift detection on a CloudFormation stack, polls until the 

6detection completes, and publishes an SNS notification if any resources 

7have drifted. 

8 

9Environment Variables: 

10 STACK_NAME: Name of the CloudFormation stack to check for drift 

11 SNS_TOPIC_ARN: ARN of the SNS topic to publish drift alerts to 

12 REGION: AWS region (populated automatically by Lambda) 

13 POLL_INTERVAL_SECONDS: Seconds between detection status polls (default: 10) 

14 POLL_MAX_ATTEMPTS: Max poll attempts before giving up (default: 60 = 10 min) 

15 

16Invocation: 

17 Triggered by an EventBridge rule. The event payload is ignored — the stack 

18 name comes from the environment so the Lambda is bound to a specific stack 

19 at deploy time (one Lambda per stack). 

20 

21Drift detection is asynchronous in CloudFormation: 

22 1. DetectStackDrift returns a DriftDetectionId 

23 2. Poll DescribeStackDriftDetectionStatus until DetectionStatus is COMPLETE or FAILED 

24 3. If drift is detected (StackDriftStatus != IN_SYNC), fetch per-resource drifts 

25 via DescribeStackResourceDrifts and publish a summary to SNS 

26""" 

27 

28from __future__ import annotations 

29 

30import json 

31import logging 

32import os 

33import time 

34from typing import Any, cast 

35 

36import boto3 

37 

38# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

39# Generated at (UTC): 2026-09-01T14:42:56Z 

40# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc 

41# Flowchart(s) generated from this file: 

42# * ``lambda_handler`` -> ``diagrams/code_diagrams/lambda/drift-detection/handler.lambda_handler.html`` 

43# (PNG: ``diagrams/code_diagrams/lambda/drift-detection/handler.lambda_handler.png``) 

44# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

45# <pyflowchart-code-diagram> END 

46 

47 

48logger = logging.getLogger() 

49logger.setLevel(logging.INFO) 

50 

51# Poll configuration — kept short to stay well within Lambda's 15-minute max 

52DEFAULT_POLL_INTERVAL_SECONDS = 10 

53DEFAULT_POLL_MAX_ATTEMPTS = 60 # 10 minutes total at 10-second intervals 

54 

55# Terminal detection statuses returned by DescribeStackDriftDetectionStatus 

56TERMINAL_DETECTION_STATUSES = {"DETECTION_COMPLETE", "DETECTION_FAILED"} 

57 

58 

59def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]: 

60 """Run CloudFormation drift detection and publish SNS alerts on drift. 

61 

62 Args: 

63 event: EventBridge scheduled event (payload unused) 

64 context: Lambda context 

65 

66 Returns: 

67 Dict with drift detection results for logging/debugging 

68 """ 

69 stack_name = os.environ.get("STACK_NAME") 

70 sns_topic_arn = os.environ.get("SNS_TOPIC_ARN") 

71 region = os.environ.get("REGION") or os.environ.get("AWS_REGION") 

72 

73 if not stack_name: 

74 raise ValueError("STACK_NAME environment variable is required") 

75 if not sns_topic_arn: 

76 raise ValueError("SNS_TOPIC_ARN environment variable is required") 

77 

78 poll_interval = int(os.environ.get("POLL_INTERVAL_SECONDS", str(DEFAULT_POLL_INTERVAL_SECONDS))) 

79 poll_max_attempts = int(os.environ.get("POLL_MAX_ATTEMPTS", str(DEFAULT_POLL_MAX_ATTEMPTS))) 

80 

81 logger.info( 

82 "Starting drift detection for stack=%s region=%s", 

83 stack_name, 

84 region, 

85 ) 

86 

87 cfn = boto3.client("cloudformation", region_name=region) 

88 sns = boto3.client("sns", region_name=region) 

89 

90 # 1. Initiate drift detection 

91 detection_id = cfn.detect_stack_drift(StackName=stack_name)["StackDriftDetectionId"] 

92 logger.info("Drift detection initiated: detection_id=%s", detection_id) 

93 

94 # 2. Poll until detection reaches a terminal state (or we time out) 

95 status_response = _poll_detection_status(cfn, detection_id, poll_interval, poll_max_attempts) 

96 detection_status = status_response.get("DetectionStatus") 

97 stack_drift_status = status_response.get("StackDriftStatus") 

98 

99 logger.info( 

100 "Drift detection finished: detection_status=%s stack_drift_status=%s", 

101 detection_status, 

102 stack_drift_status, 

103 ) 

104 

105 # 3. If detection failed, alert on the failure itself 

106 if detection_status == "DETECTION_FAILED": 

107 reason = status_response.get("DetectionStatusReason", "Unknown detection failure") 

108 _publish_alert( 

109 sns, 

110 sns_topic_arn, 

111 subject=f"[GCO] Drift detection FAILED for {stack_name}", 

112 message={ 

113 "stack_name": stack_name, 

114 "region": region, 

115 "detection_status": detection_status, 

116 "reason": reason, 

117 }, 

118 ) 

119 return { 

120 "stack_name": stack_name, 

121 "detection_status": detection_status, 

122 "stack_drift_status": None, 

123 "drift_published": True, 

124 } 

125 

126 # 4. If stack is in sync, nothing to alert on 

127 if stack_drift_status == "IN_SYNC": 

128 logger.info("Stack %s is IN_SYNC — no alert published", stack_name) 

129 return { 

130 "stack_name": stack_name, 

131 "detection_status": detection_status, 

132 "stack_drift_status": stack_drift_status, 

133 "drift_published": False, 

134 } 

135 

136 # 5. Drift detected — fetch drifted resources and publish SNS alert 

137 drifted_resources = _list_drifted_resources(cfn, stack_name) 

138 _publish_alert( 

139 sns, 

140 sns_topic_arn, 

141 subject=f"[GCO] Drift detected in stack {stack_name}", 

142 message={ 

143 "stack_name": stack_name, 

144 "region": region, 

145 "stack_drift_status": stack_drift_status, 

146 "drifted_resource_count": len(drifted_resources), 

147 "drifted_resources": drifted_resources, 

148 }, 

149 ) 

150 

151 return { 

152 "stack_name": stack_name, 

153 "detection_status": detection_status, 

154 "stack_drift_status": stack_drift_status, 

155 "drifted_resource_count": len(drifted_resources), 

156 "drift_published": True, 

157 } 

158 

159 

160def _poll_detection_status( 

161 cfn: Any, 

162 detection_id: str, 

163 poll_interval: int, 

164 max_attempts: int, 

165) -> dict[str, Any]: 

166 """Poll DescribeStackDriftDetectionStatus until terminal or timeout.""" 

167 for attempt in range(max_attempts): 

168 response = cfn.describe_stack_drift_detection_status(StackDriftDetectionId=detection_id) 

169 status = response.get("DetectionStatus") 

170 logger.debug("Poll attempt %d: detection_status=%s", attempt + 1, status) 

171 if status in TERMINAL_DETECTION_STATUSES: 

172 return cast("dict[str, Any]", response) 

173 time.sleep(poll_interval) 

174 

175 # Timed out — return last response so caller can decide what to do 

176 logger.warning( 

177 "Drift detection did not complete within %d polls; returning last status", 

178 max_attempts, 

179 ) 

180 return cast("dict[str, Any]", response) 

181 

182 

183def _list_drifted_resources(cfn: Any, stack_name: str) -> list[dict[str, str]]: 

184 """List resources with a drift status other than IN_SYNC. 

185 

186 Returns a trimmed representation of each drifted resource suitable for 

187 embedding in an SNS message. 

188 """ 

189 drifted: list[dict[str, str]] = [] 

190 paginator = cfn.get_paginator("describe_stack_resource_drifts") 

191 # Filter server-side to reduce response size for large stacks 

192 drift_filter = ["MODIFIED", "DELETED", "NOT_CHECKED"] 

193 for page in paginator.paginate( 

194 StackName=stack_name, StackResourceDriftStatusFilters=drift_filter 

195 ): 

196 for resource in page.get("StackResourceDrifts", []): 

197 drifted.append( 

198 { 

199 "logical_id": resource.get("LogicalResourceId", ""), 

200 "physical_id": resource.get("PhysicalResourceId", ""), 

201 "resource_type": resource.get("ResourceType", ""), 

202 "drift_status": resource.get("StackResourceDriftStatus", ""), 

203 } 

204 ) 

205 return drifted 

206 

207 

208def _publish_alert(sns: Any, topic_arn: str, subject: str, message: dict[str, Any]) -> None: 

209 """Publish a JSON alert to SNS. Subject is truncated to the 100-char limit.""" 

210 # SNS subjects have a 100-char max; truncate defensively 

211 truncated_subject = subject[:100] 

212 sns.publish( 

213 TopicArn=topic_arn, 

214 Subject=truncated_subject, 

215 Message=json.dumps(message, indent=2, default=str), 

216 ) 

217 logger.info("Published drift alert to %s", topic_arn)