Coverage for scripts / live_release_validation / protected.py: 100.00%

183 statements  

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

1"""Protected-resource identity matching for the ownership boundary.""" 

2 

3from __future__ import annotations 

4 

5import re 

6from collections.abc import Mapping 

7from typing import Any 

8from urllib.parse import urlsplit 

9 

10_PROTECTED_REGIONAL_RESOURCE_CATEGORIES = { 

11 "AWS::ApiGateway::RestApi": "api_gateway_v1_apis", 

12 "AWS::ApiGatewayV2::Api": "api_gateway_v2_apis", 

13 "AWS::Backup::BackupPlan": "backup_plans", 

14 "AWS::Backup::BackupSelection": "backup_selections", 

15 "AWS::Backup::BackupVault": "backup_vaults", 

16 "AWS::DynamoDB::Table": "dynamodb_tables", 

17 "AWS::EC2::EIP": "elastic_ips", 

18 "AWS::EC2::FlowLog": "flow_logs", 

19 "AWS::EC2::Instance": "instances", 

20 "AWS::EC2::NatGateway": "nat_gateways", 

21 "AWS::EC2::NetworkInterface": "network_interfaces", 

22 "AWS::EC2::SecurityGroup": "security_groups", 

23 "AWS::EC2::Subnet": "subnets", 

24 "AWS::EC2::VPC": "vpcs", 

25 "AWS::ECR::Repository": "ecr_repositories", 

26 "AWS::EKS::Cluster": "eks_clusters", 

27 "AWS::ElasticLoadBalancingV2::LoadBalancer": "load_balancers", 

28 "AWS::ElasticLoadBalancingV2::TargetGroup": "target_groups", 

29 "AWS::KMS::Key": "kms_keys", 

30 "AWS::Lambda::Function": "lambda_functions", 

31 "AWS::Logs::LogGroup": "cloudwatch_log_groups", 

32 "AWS::SQS::Queue": "sqs_queues", 

33 "AWS::SecretsManager::Secret": "secrets", 

34} 

35 

36 

37_PROTECTED_GLOBAL_RESOURCE_CATEGORIES = { 

38 "AWS::GlobalAccelerator::Accelerator": "global_accelerators", 

39 "AWS::IAM::Group": "iam_groups", 

40 "AWS::IAM::InstanceProfile": "iam_instance_profiles", 

41 "AWS::IAM::ManagedPolicy": "iam_policies", 

42 "AWS::IAM::Role": "iam_roles", 

43 "AWS::IAM::User": "iam_users", 

44 "AWS::S3::Bucket": "s3_buckets", 

45} 

46 

47 

48_IAM_ARN_RESOURCE_KINDS = { 

49 "AWS::IAM::Group": "group", 

50 "AWS::IAM::InstanceProfile": "instance-profile", 

51 "AWS::IAM::ManagedPolicy": "policy", 

52 "AWS::IAM::Role": "role", 

53 "AWS::IAM::User": "user", 

54} 

55 

56 

57_BACKUP_ARN_RESOURCE_PREFIXES = { 

58 "AWS::Backup::BackupPlan": "backup-plan:", 

59 "AWS::Backup::BackupVault": "backup-vault:", 

60} 

61 

62 

63_EC2_ID_SUFFIX = r"(?:[0-9a-f]{8}|[0-9a-f]{17})" 

64 

65 

66_EC2_TAGGED_RESOURCE_IDENTITIES: dict[str, tuple[str, re.Pattern[str]]] = { 

67 "elastic-ip": ("elastic_ips", re.compile(rf"eipalloc-{_EC2_ID_SUFFIX}")), 

68 "instance": ("instances", re.compile(rf"i-{_EC2_ID_SUFFIX}")), 

69 "natgateway": ("nat_gateways", re.compile(rf"nat-{_EC2_ID_SUFFIX}")), 

70 "network-interface": ("network_interfaces", re.compile(rf"eni-{_EC2_ID_SUFFIX}")), 

71 "security-group": ("security_groups", re.compile(rf"sg-{_EC2_ID_SUFFIX}")), 

72 "subnet": ("subnets", re.compile(rf"subnet-{_EC2_ID_SUFFIX}")), 

73 "vpc": ("vpcs", re.compile(rf"vpc-{_EC2_ID_SUFFIX}")), 

74 "vpc-flow-log": ("flow_logs", re.compile(rf"fl-{_EC2_ID_SUFFIX}")), 

75} 

76 

77 

78_EKS_CLUSTER_NAME = re.compile(r"[0-9A-Za-z][A-Za-z0-9_-]{0,99}") 

79 

80 

81_EKS_ASSOCIATION_ID = re.compile(r"a-[0-9a-z]{17}") 

82 

83 

84_KUBERNETES_DNS_LABEL = re.compile(r"[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?") 

85 

86 

87_KUBERNETES_POD_UID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") 

88 

89 

90_CLOUDFORMATION_STACK_ID_TAG = "aws:cloudformation:stack-id" 

91 

92 

93_SQS_DNS_SUFFIXES = { 

94 "aws": "amazonaws.com", 

95 "aws-cn": "amazonaws.com.cn", 

96 "aws-us-gov": "amazonaws.com", 

97} 

98 

99 

100def _baseline_protected_identities( 

101 baseline: dict[str, Any], 

102) -> tuple[dict[str, set[str]], dict[str, dict[str, set[str]]]]: 

103 """Validate and index exact protected stack and physical-resource identities.""" 

104 protected_stacks = baseline.get("protected_stacks") 

105 if protected_stacks is None: 

106 protected_stacks = {} 

107 elif not isinstance(protected_stacks, dict): 

108 raise RuntimeError("Baseline protected_stacks must be an object") 

109 

110 stack_ids: dict[str, set[str]] = {} 

111 resource_ids: dict[str, dict[str, set[str]]] = {} 

112 for raw_region, stacks in protected_stacks.items(): 

113 region = str(raw_region) 

114 if not isinstance(stacks, list): 

115 raise RuntimeError(f"Protected stack baseline for {region} must be a list") 

116 for stack in stacks: 

117 if not isinstance(stack, dict): 

118 raise RuntimeError(f"Protected stack baseline for {region} is malformed") 

119 stack_id = str(stack.get("stack_id") or "") 

120 if not stack_id: 

121 raise RuntimeError(f"Protected stack baseline for {region} omitted its stack ID") 

122 stack_ids.setdefault(region, set()).add(stack_id) 

123 resources = stack.get("physical_resources") 

124 if not isinstance(resources, list): 

125 raise RuntimeError( 

126 f"Protected stack baseline {region}:{stack_id} omitted physical resources" 

127 ) 

128 for resource in resources: 

129 if not isinstance(resource, dict): 

130 raise RuntimeError( 

131 f"Protected stack baseline {region}:{stack_id} has a malformed resource" 

132 ) 

133 resource_type = str(resource.get("resource_type") or "") 

134 physical_id = str(resource.get("physical_id") or "") 

135 logical_id = str(resource.get("logical_id") or "") 

136 if not resource_type or not physical_id or not logical_id: 

137 raise RuntimeError( 

138 f"Protected stack baseline {region}:{stack_id} has an incomplete resource" 

139 ) 

140 resource_ids.setdefault(region, {}).setdefault(resource_type, set()).add( 

141 physical_id 

142 ) 

143 return stack_ids, resource_ids 

144 

145 

146def _iam_arn_name(arn: str, resource_kind: str) -> str | None: 

147 parts = arn.split(":", 5) 

148 if len(parts) != 6 or parts[0] != "arn" or parts[2] != "iam": 

149 return None 

150 prefix = f"{resource_kind}/" 

151 resource = parts[5] 

152 if not resource.startswith(prefix): 

153 return None 

154 name = resource[len(prefix) :].rsplit("/", 1)[-1] 

155 return name or None 

156 

157 

158def _lambda_arn_name(arn: str) -> str | None: 

159 parts = arn.split(":", 5) 

160 if len(parts) != 6 or parts[0] != "arn" or parts[2] != "lambda": 

161 return None 

162 resource = parts[5] 

163 if not resource.startswith("function:"): 

164 return None 

165 name = resource.removeprefix("function:") 

166 return name if name and ":" not in name else None 

167 

168 

169def _backup_arn_physical_id(arn: str, resource_prefix: str) -> str | None: 

170 parts = arn.split(":", 5) 

171 if len(parts) != 6 or parts[0] != "arn" or parts[2] != "backup": 

172 return None 

173 resource = parts[5] 

174 if not resource.startswith(resource_prefix): 

175 return None 

176 physical_id = resource.removeprefix(resource_prefix) 

177 return physical_id or None 

178 

179 

180def _sqs_queue_name_from_physical_id( 

181 physical_id: str, 

182 *, 

183 partition: str, 

184 region: str, 

185 account_id: str, 

186) -> str | None: 

187 """Normalize an exact SQS queue name or CloudFormation queue URL.""" 

188 if "://" not in physical_id: 

189 return physical_id or None 

190 

191 dns_suffix = _SQS_DNS_SUFFIXES.get(partition) 

192 parsed = urlsplit(physical_id) 

193 if ( 

194 dns_suffix is None 

195 or parsed.scheme != "https" 

196 or parsed.hostname != f"sqs.{region}.{dns_suffix}" 

197 or parsed.query 

198 or parsed.fragment 

199 ): 

200 return None 

201 path_parts = parsed.path.strip("/").split("/") 

202 if len(path_parts) != 2 or path_parts[0] != account_id: 

203 return None 

204 return path_parts[1] or None 

205 

206 

207def _tagged_arn_matches_protected_physical_id( 

208 resource_type: str, 

209 arn: str, 

210 physical_id: str, 

211 *, 

212 expected_partition: str, 

213 expected_region: str, 

214 expected_account: str, 

215) -> bool: 

216 """Match a Tagging API ARN to one exact CloudFormation physical ID.""" 

217 if arn == physical_id: 

218 return True 

219 

220 parts = arn.split(":", 5) 

221 if len(parts) != 6 or parts[0] != "arn": 

222 return False 

223 partition, service, region, account_id, resource = ( 

224 parts[1], 

225 parts[2], 

226 parts[3], 

227 parts[4], 

228 parts[5], 

229 ) 

230 

231 if resource_type == "AWS::Lambda::Function": 

232 return _lambda_arn_name(arn) == physical_id 

233 if resource_type == "AWS::DynamoDB::Table": 

234 return ( 

235 service == "dynamodb" 

236 and resource.startswith("table/") 

237 and "/" not in resource.removeprefix("table/") 

238 and resource.removeprefix("table/") == physical_id 

239 ) 

240 if resource_type == "AWS::S3::Bucket": 

241 return ( 

242 service == "s3" and bool(resource) and "/" not in resource and resource == physical_id 

243 ) 

244 if resource_type == "AWS::SQS::Queue": 

245 if service != "sqs" or not region or not account_id or not resource: 

246 return False 

247 queue_name = _sqs_queue_name_from_physical_id( 

248 physical_id, 

249 partition=partition, 

250 region=region, 

251 account_id=account_id, 

252 ) 

253 return queue_name == resource 

254 if resource_type == "AWS::KMS::Key": 

255 return ( 

256 service == "kms" 

257 and resource.startswith("key/") 

258 and "/" not in resource.removeprefix("key/") 

259 and resource.removeprefix("key/") == physical_id 

260 ) 

261 expected_ec2_category = { 

262 "AWS::EC2::FlowLog": "flow_logs", 

263 "AWS::EC2::NatGateway": "nat_gateways", 

264 }.get(resource_type) 

265 if expected_ec2_category is not None: 

266 return bool( 

267 expected_partition 

268 and expected_region 

269 and re.fullmatch(r"\d{12}", expected_account) 

270 and _ec2_tagged_resource_identity( 

271 arn, 

272 expected_region, 

273 expected_partition, 

274 expected_account, 

275 ) 

276 == (expected_ec2_category, physical_id) 

277 ) 

278 return False 

279 

280 

281def _tagged_resource_is_protected( 

282 record: Any, 

283 *, 

284 protected_stack_ids: set[str], 

285 protected_resource_ids: dict[str, set[str]], 

286 exact_arns: set[str], 

287 expected_partition: str, 

288 expected_region: str, 

289 expected_account: str, 

290) -> bool: 

291 """Return whether a tagged record has one exact protected identity.""" 

292 if not isinstance(record, Mapping): 

293 return False 

294 arn = str(record.get("arn") or "") 

295 if not arn: 

296 return False 

297 if arn in exact_arns: 

298 return True 

299 

300 tags = record.get("tags") 

301 if isinstance(tags, Mapping): 

302 stack_id = str(tags.get(_CLOUDFORMATION_STACK_ID_TAG) or "") 

303 if stack_id in protected_stack_ids: 

304 return True 

305 

306 return any( 

307 _tagged_arn_matches_protected_physical_id( 

308 resource_type, 

309 arn, 

310 physical_id, 

311 expected_partition=expected_partition, 

312 expected_region=expected_region, 

313 expected_account=expected_account, 

314 ) 

315 for resource_type, physical_ids in protected_resource_ids.items() 

316 for physical_id in physical_ids 

317 ) 

318 

319 

320def _matches_protected_physical_identity( 

321 resource_type: str, 

322 category: str, 

323 candidate: Any, 

324 physical_id: str, 

325 *, 

326 protected_backup_plan_ids: set[str] | None = None, 

327) -> bool: 

328 """Match one inventory record without any prefix or ownership-name fallback.""" 

329 if category == "kms_keys": 

330 if not isinstance(candidate, dict): 

331 return False 

332 return physical_id in { 

333 str(candidate.get("key_id") or ""), 

334 str(candidate.get("arn") or ""), 

335 } 

336 if not isinstance(candidate, str): 

337 return False 

338 if resource_type == "AWS::Backup::BackupSelection": 

339 plan_id, separator, selection_id = candidate.partition(":") 

340 return bool( 

341 separator 

342 and selection_id == physical_id 

343 and plan_id in (protected_backup_plan_ids or set()) 

344 ) 

345 if candidate == physical_id: 

346 return True 

347 backup_prefix = _BACKUP_ARN_RESOURCE_PREFIXES.get(resource_type) 

348 if backup_prefix is not None: 

349 return _backup_arn_physical_id(candidate, backup_prefix) == physical_id 

350 iam_kind = _IAM_ARN_RESOURCE_KINDS.get(resource_type) 

351 if iam_kind is not None: 

352 return _iam_arn_name(candidate, iam_kind) == physical_id 

353 if resource_type == "AWS::Lambda::Function": 

354 return _lambda_arn_name(candidate) == physical_id 

355 return False 

356 

357 

358def _valid_kubernetes_dns_subdomain(value: str) -> bool: 

359 return bool( 

360 value 

361 and len(value) <= 253 

362 and all(_KUBERNETES_DNS_LABEL.fullmatch(label) for label in value.split(".")) 

363 ) 

364 

365 

366def _eks_pod_parent_cluster( 

367 arn: str, 

368 expected_region: str, 

369 expected_partition: str, 

370 expected_account: str, 

371) -> str | None: 

372 """Parse canonical in-scope EKS pod identities; malformed records stay visible.""" 

373 parts = arn.split(":", 5) 

374 if ( 

375 len(parts) != 6 

376 or parts[0] != "arn" 

377 or parts[1] != expected_partition 

378 or parts[2] != "eks" 

379 or parts[3] != expected_region 

380 or parts[4] != expected_account 

381 ): 

382 return None 

383 resource_parts = parts[5].split("/") 

384 if any(not component for component in resource_parts): 

385 return None 

386 if resource_parts[0] == "pod": 

387 if len(resource_parts) != 5: 

388 return None 

389 _kind, cluster, namespace, pod_name, pod_uid = resource_parts 

390 if ( 

391 not _EKS_CLUSTER_NAME.fullmatch(cluster) 

392 or not _KUBERNETES_DNS_LABEL.fullmatch(namespace) 

393 or not _valid_kubernetes_dns_subdomain(pod_name) 

394 or not _KUBERNETES_POD_UID.fullmatch(pod_uid) 

395 ): 

396 return None 

397 return cluster 

398 if resource_parts[0] == "podidentityassociation": 

399 if len(resource_parts) != 3: 

400 return None 

401 _kind, cluster, association_id = resource_parts 

402 if not _EKS_CLUSTER_NAME.fullmatch(cluster) or not _EKS_ASSOCIATION_ID.fullmatch( 

403 association_id 

404 ): 

405 return None 

406 return cluster 

407 return None 

408 

409 

410def _ec2_tagged_resource_identity( 

411 arn: str, 

412 expected_region: str, 

413 expected_partition: str, 

414 expected_account: str, 

415) -> tuple[str, str] | None: 

416 """Map only canonical in-scope EC2 ARNs to authoritative live identities.""" 

417 parts = arn.split(":", 5) 

418 if ( 

419 len(parts) != 6 

420 or parts[0] != "arn" 

421 or parts[1] != expected_partition 

422 or parts[2] != "ec2" 

423 or parts[3] != expected_region 

424 or parts[4] != expected_account 

425 ): 

426 return None 

427 resource_kind, separator, resource_id = parts[5].partition("/") 

428 identity = _EC2_TAGGED_RESOURCE_IDENTITIES.get(resource_kind) 

429 if not separator or not resource_id or "/" in resource_id or identity is None: 

430 return None 

431 category, resource_id_pattern = identity 

432 if not resource_id_pattern.fullmatch(resource_id): 

433 return None 

434 return category, resource_id