Skip to content

Commit ad933fd

Browse files
authored
feat/SA-384: cost anomaly parsing and rendering (#41)
* feat: sa-384 cost anomaly event parsed and rendered
1 parent 6d1c219 commit ad933fd

File tree

7 files changed

+355
-20
lines changed

7 files changed

+355
-20
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ module "notifications" {
4848
error_url = "https://raw.githubusercontent.com/appvia/terraform-aws-notifications/main/resources/posts-attention-icon.png"
4949
warning_url = "https://raw.githubusercontent.com/appvia/terraform-aws-notifications/main/resources/posts-warning-icon.png"
5050
}
51+
52+
# To redirect event URL in post through Identity Center, e.g.:
53+
identity_center_start_url = "<your start url>"
54+
identity_center_role = "<your role - consistent across all accounts - namely read only>
5155
}
5256
```
5357

@@ -130,7 +134,7 @@ Frequently (quartley at least) check and upgrade:
130134
| <a name="input_enable_teams"></a> [enable\_teams](#input\_enable\_teams) | To send to teams, set to true | `bool` | `false` | no |
131135
| <a name="input_identity_center_role"></a> [identity\_center\_role](#input\_identity\_center\_role) | The name of the role to use when redirecting through Identity Center | `string` | `null` | no |
132136
| <a name="input_identity_center_start_url"></a> [identity\_center\_start\_url](#input\_identity\_center\_start\_url) | The start URL of your Identity Center instance | `string` | `null` | no |
133-
| <a name="input_post_icons_url"></a> [post\_icons\_url](#input\_post\_icons\_url) | URLs (not base64 encoded!) to publically available icons for highlighting posts of error and/or warning status. Ideally 50px square. Set to non-existent URLs to disable icons | <pre>object({<br> error_url = string<br> warning_url = string<br> })</pre> | <pre>{<br> "error_url": "https://sa-251-emblems.s3.eu-west-1.amazonaws.com/attention-50px.png",<br> "warning_url": "https://sa-251-emblems.s3.eu-west-1.amazonaws.com/warning-50px.png"<br>}</pre> | no |
137+
| <a name="input_post_icons_url"></a> [post\_icons\_url](#input\_post\_icons\_url) | URLs (not base64 encoded!) to publically available icons for highlighting posts of error and/or warning status. Ideally 50px square. Set to non-existent URLs to disable icons | <pre>object({<br> error_url = string<br> warning_url = string<br> })</pre> | <pre>{<br> "error_url": "https://raw.githubusercontent.com/appvia/terraform-aws-notifications/main/resources/posts-attention-icon.png",<br> "warning_url": "https://raw.githubusercontent.com/appvia/terraform-aws-notifications/main/resources/posts-warning-icon.png"<br>}</pre> | no |
134138
| <a name="input_slack"></a> [slack](#input\_slack) | The configuration for Slack notifications | <pre>object({<br> lambda_name = optional(string, "slack-notify")<br> # The name of the lambda function to create <br> lambda_description = optional(string, "Lambda function to send slack notifications")<br> # The description for the slack lambda<br> secret_name = optional(string)<br> # An optional secret name in secrets manager to use for the slack configuration <br> webhook_url = optional(string)<br> # The webhook url to post to<br> filter_policy = optional(string)<br> # An optional SNS subscription filter policy to apply<br> filter_policy_scope = optional(string)<br> # If filter policy provided this is the scope of that policy; either "MessageAttributes" (default) or "MessageBody"<br> })</pre> | `null` | no |
135139
| <a name="input_sns_topic_policy"></a> [sns\_topic\_policy](#input\_sns\_topic\_policy) | The policy to attach to the sns topic, else we default to account root | `string` | `null` | no |
136140
| <a name="input_subscribers"></a> [subscribers](#input\_subscribers) | Optional list of custom subscribers to the SNS topic | <pre>map(object({<br> protocol = string<br> # The protocol to use. The possible values for this are: sqs, sms, lambda, application. (http or https are partially supported, see below).<br> endpoint = string<br> # The endpoint to send data to, the contents will vary with the protocol. (see below for more information)<br> endpoint_auto_confirms = bool<br> # Boolean indicating whether the end point is capable of auto confirming subscription e.g., PagerDuty (default is false)<br> raw_message_delivery = bool<br> # Boolean indicating whether or not to enable raw message delivery (the original message is directly passed, not wrapped in JSON with the original message in the message property) (default is false)<br> }))</pre> | `{}` | no |

modules/notify/README.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@ Subsumed by appvia's GNU V3 license; [see license](../../LICENSE).
132132
|------|-------------|------|---------|:--------:|
133133
| <a name="input_accounts_id_to_name"></a> [accounts\_id\_to\_name](#input\_accounts\_id\_to\_name) | A mapping of account id and account name - used by notification lamdba to map an account ID to a human readable name | `map(string)` | `{}` | no |
134134
| <a name="input_architecture"></a> [architecture](#input\_architecture) | Instruction set architecture for your Lambda function. Valid values are "x86\_64" or "arm64". | `string` | `"arm64"` | no |
135-
| <a name="input_aws_powertools_log_level"></a> [aws\_powertools\_log\_level](#input\_aws\_powertools\_log\_level) | The log level for aws powertools | `string` | `"DEBUG"` | no |
136135
| <a name="input_aws_powertools_service_name"></a> [aws\_powertools\_service\_name](#input\_aws\_powertools\_service\_name) | The service name to use | `string` | `"appvia-notifications"` | no |
137136
| <a name="input_cloudwatch_log_group_kms_key_id"></a> [cloudwatch\_log\_group\_kms\_key\_id](#input\_cloudwatch\_log\_group\_kms\_key\_id) | The ARN of the KMS Key to use when encrypting log data for Lambda | `string` | `null` | no |
138137
| <a name="input_cloudwatch_log_group_retention_in_days"></a> [cloudwatch\_log\_group\_retention\_in\_days](#input\_cloudwatch\_log\_group\_retention\_in\_days) | Specifies the number of days you want to retain log events in log group for Lambda. | `number` | `0` | no |
@@ -143,7 +142,6 @@ Subsumed by appvia's GNU V3 license; [see license](../../LICENSE).
143142
| <a name="input_enable_slack"></a> [enable\_slack](#input\_enable\_slack) | To send to slack, set to true | `bool` | `false` | no |
144143
| <a name="input_enable_sns_topic_delivery_status_logs"></a> [enable\_sns\_topic\_delivery\_status\_logs](#input\_enable\_sns\_topic\_delivery\_status\_logs) | Whether to enable SNS topic delivery status logs | `bool` | `false` | no |
145144
| <a name="input_enable_teams"></a> [enable\_teams](#input\_enable\_teams) | To send to teams, set to true | `bool` | `false` | no |
146-
| <a name="input_hash_extra"></a> [hash\_extra](#input\_hash\_extra) | The string to add into hashing function. Useful when building same source path for different functions. | `string` | `""` | no |
147145
| <a name="input_iam_policy_path"></a> [iam\_policy\_path](#input\_iam\_policy\_path) | Path of policies to that should be added to IAM role for Lambda Function | `string` | `null` | no |
148146
| <a name="input_iam_role_boundary_policy_arn"></a> [iam\_role\_boundary\_policy\_arn](#input\_iam\_role\_boundary\_policy\_arn) | The ARN of the policy that is used to set the permissions boundary for the role | `string` | `null` | no |
149147
| <a name="input_iam_role_name_prefix"></a> [iam\_role\_name\_prefix](#input\_iam\_role\_name\_prefix) | A unique role name beginning with the specified prefix | `string` | `"lambda"` | no |
@@ -167,7 +165,6 @@ Subsumed by appvia's GNU V3 license; [see license](../../LICENSE).
167165
| <a name="input_python_runtime"></a> [python\_runtime](#input\_python\_runtime) | The lambda python runtime | `string` | `"python3.12"` | no |
168166
| <a name="input_recreate_missing_package"></a> [recreate\_missing\_package](#input\_recreate\_missing\_package) | Whether to recreate missing Lambda package if it is missing locally or not | `bool` | `true` | no |
169167
| <a name="input_reserved_concurrent_executions"></a> [reserved\_concurrent\_executions](#input\_reserved\_concurrent\_executions) | The amount of reserved concurrent executions for this lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations | `number` | `-1` | no |
170-
| <a name="input_slack_emoji"></a> [slack\_emoji](#input\_slack\_emoji) | A custom emoji that will appear on Slack messages | `string` | `":aws:"` | no |
171168
| <a name="input_sns_topic_feedback_role_description"></a> [sns\_topic\_feedback\_role\_description](#input\_sns\_topic\_feedback\_role\_description) | Description of IAM role to use for SNS topic delivery status logging | `string` | `null` | no |
172169
| <a name="input_sns_topic_feedback_role_force_detach_policies"></a> [sns\_topic\_feedback\_role\_force\_detach\_policies](#input\_sns\_topic\_feedback\_role\_force\_detach\_policies) | Specifies to force detaching any policies the IAM role has before destroying it. | `bool` | `true` | no |
173170
| <a name="input_sns_topic_feedback_role_name"></a> [sns\_topic\_feedback\_role\_name](#input\_sns\_topic\_feedback\_role\_name) | Name of the IAM role to use for SNS topic delivery status logging | `string` | `null` | no |

modules/notify/functions/src/msg_parser.py

Lines changed: 105 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838

3939
class AwsService(Enum):
4040
"""AWS service supported by function"""
41-
4241
cloudwatch = "cloudwatch"
4342
guardduty = "guardduty"
4443
securityhub = "securityhub"
@@ -58,10 +57,10 @@ def decrypt_url(encrypted_url: str) -> str:
5857
except Exception as e:
5958
raise e
6059

61-
def get_service_url(region: str, service: str, account_id: str) -> str:
60+
def get_service_url(region: str, service: str) -> str:
6261
"""Get the appropriate service URL for the region
6362
64-
:param region: name of the AWS region
63+
:param region: name of the AWS region - not used on a
6564
:param service: name of the AWS service
6665
:returns: AWS console url formatted for the region and service provided
6766
"""
@@ -72,16 +71,26 @@ def get_service_url(region: str, service: str, account_id: str) -> str:
7271

7372
if region.startswith("us-gov-"):
7473
return f"https://console.amazonaws-us-gov.com/{service_url}"
75-
elif IDENTITY_CENTER_URL.__len__ and account_id != None:
76-
destination_url = f"https://{region}.console.aws.amazon.com/{service_url}"
77-
return f"{IDENTITY_CENTER_URL}/#/console?account_id={account_id}&role_name={IDENTITY_CENTER_ROLE}&destination={urllib.parse.quote(destination_url)}"
7874
else:
7975
return f"https://console.aws.amazon.com/{service_url}"
8076

8177
except KeyError:
8278
print(f"Service {service} is currently not supported")
8379
raise
8480

81+
def get_target_url(account_id: str, absoluteUrl: str = None) -> str:
82+
"""Redirect via identity center if defined
83+
84+
:param account_id: the originating account id to use when using Identity Center redirects
85+
:param absoluteUrl: if the service is "absolute", then this is the target url
86+
:returns: AWS console url formatted for the given URL, account & role iv via Identity Center
87+
"""
88+
if len(IDENTITY_CENTER_URL) > 0 and account_id != None:
89+
return f"{IDENTITY_CENTER_URL}/#/console?account_id={account_id}&role_name={IDENTITY_CENTER_ROLE}&destination={urllib.parse.quote(absoluteUrl)}"
90+
else:
91+
return f"{absoluteUrl}"
92+
93+
8594
class AwsAction(Enum):
8695
"""The individual AWS service types parsed"""
8796
CLOUDWATCH = "CloudWatch"
@@ -92,6 +101,7 @@ class AwsAction(Enum):
92101
SAVINGS_PLAN = "SavingsPlan"
93102
SECURITY_HUB = "SecurityHub"
94103
DMS = "DMS"
104+
COST_ANOMALY = "CostAnomaly"
95105
UNKNOWN = "Unknown"
96106

97107
class CloudWatchAlarmPriority(Enum):
@@ -124,7 +134,7 @@ def parse_cloudwatch_alarm(message: Dict[str, Any], snsRegion: str) -> Dict[str,
124134
alarm_arn = message["AlarmArn"]
125135
alarm_arn_region = message["AlarmArn"].split(":")[3]
126136

127-
cloudwatch_service_url = get_service_url(region=alarm_arn_region, service="cloudwatch", account_id=account_id)
137+
cloudwatch_service_url = get_target_url(account_id=account_id, absoluteUrl=get_service_url(region=alarm_arn_region, service="cloudwatch"))
128138
cloudwatch_url = f"{cloudwatch_service_url}#alarm:alarmFilter=ANY;name={urllib.parse.quote(name)}"
129139

130140
return {
@@ -187,7 +197,7 @@ def parse_guardduty_finding(message: Dict[str, Any], snsRegion: str) -> Dict[str
187197
count = service['count']
188198
guard_duty_id = detail['id']
189199

190-
guardduty_url = get_service_url(region=region, service="guardduty", account_id=account_id)
200+
guardduty_url = get_target_url(account_id=account_id, absoluteUrl=get_service_url(region=region, service="guardduty"))
191201

192202

193203
atDT = datetime.fromisoformat(service["eventLastSeen"])
@@ -440,8 +450,8 @@ def parse_security_hub_finding(message: Dict[str, Any], snsRegion: str) -> Dict[
440450
region = message["FindingId"].split(":")[3]
441451

442452
# not done any real investigztion into the service url yet!!!!!!
443-
service_url = get_service_url(region=region, service="securityhub", account_id=account_id)
444-
url = f"{service_url}#finding:findindFilter=ANY;rule={urllib.parse.quote(rule_id)}"
453+
service_url = get_target_url(account_id=account_id, absoluteUrl=get_service_url(region=region, service="securityhub"))
454+
url = f"{service_url}#findings?search=GeneratorId%3D%255Coperator%255C%253AEQUALS%255C%253A{urllib.parse.quote(source)}"
445455

446456
# light touch parse on each resource
447457
resources:list[Dict[str, Any]] = []
@@ -508,6 +518,87 @@ def parse_dms_notification(message: Dict[str, Any], snsRegion: str) -> Dict[str,
508518
"at_epoch": floor(atEpoch),
509519
}
510520

521+
522+
class CostAnomalyPriority(Enum):
523+
"""Maps Cost Anomaly severity state to a normalised 3 level priority"""
524+
GOOD = "GOOD"
525+
WARNING = "WARNING"
526+
ERROR = "ERROR"
527+
528+
def parse_cost_anomaly(message: Dict[str, Any]) -> Dict[str, Any]:
529+
"""Format Cost Anomaly event into facts
530+
531+
:params message: SNS message body containing Security Hub event
532+
:returns: Cost Anomaly facts
533+
"""
534+
535+
# if the anomaly current score is lower than max then it's a warning
536+
# if the anomaly current score is equal to max then it's an error (the current event is the max)
537+
anomaly_score = message["anomalyScore"]
538+
current_anomaly_score = anomaly_score["currentScore"]
539+
max_anomaly_score = anomaly_score["maxScore"]
540+
541+
if current_anomaly_score < max_anomaly_score:
542+
priority = CostAnomalyPriority.WARNING.value
543+
else:
544+
priority = CostAnomalyPriority.ERROR.value
545+
546+
# to use for the identity center style url
547+
originatingAccountId = message["accountId"]
548+
originatingUrl = message["anomalyDetailsLink"]
549+
url = get_target_url(account_id=originatingAccountId, absoluteUrl=originatingUrl)
550+
551+
# start and end
552+
startedAt = message["anomalyStartDate"]
553+
startedAtDT = datetime.fromisoformat(startedAt)
554+
startedAtEpoch = startedAtDT.timestamp()
555+
endedAt = message["anomalyEndDate"]
556+
endedAtDT = datetime.fromisoformat(endedAt)
557+
endedAtEpoch = endedAtDT.timestamp()
558+
559+
# the anomaly
560+
anomaly_id = message["anomalyId"]
561+
anomaly_dimension = message["dimensionalValue"]
562+
monitor_name = message["monitorName"]
563+
564+
# the impact
565+
impact = message["impact"]
566+
expected_spend = impact["totalExpectedSpend"]
567+
actual_spend = impact["totalActualSpend"]
568+
total_impact = impact["totalImpact"]
569+
570+
# cost anomaly includes the account id and name that originated the anomaly (linked)
571+
# assuming the first root cause
572+
rootCauses = message["rootCauses"][0]
573+
account_id = rootCauses["linkedAccount"]
574+
account_name = rootCauses["linkedAccountName"]
575+
region = rootCauses["region"]
576+
service = rootCauses["service"]
577+
usage = rootCauses["usageType"]
578+
579+
return {
580+
"action": AwsAction.COST_ANOMALY.value,
581+
"priority": priority,
582+
583+
"started": startedAt,
584+
"ended": endedAt,
585+
586+
"anomaly_id": anomaly_id,
587+
"monitor_name": monitor_name,
588+
589+
"expected_spend": expected_spend,
590+
"actual_spend": actual_spend,
591+
"total_impact": total_impact,
592+
593+
"account_id": account_id,
594+
"account_name": account_name,
595+
"region": region,
596+
"service": service,
597+
"usage": usage,
598+
599+
"url": url,
600+
}
601+
511602
class AwsParsedMessage:
512603
parsedMsg: Dict[str, Any]
513604
originalMsg: Dict[str, Any]
@@ -537,7 +628,6 @@ def get_message_payload(
537628
try:
538629
message = json.loads(message)
539630
except json.JSONDecodeError:
540-
# logger.debug("SNS Record 'message' is not a structured (JSON) payload; it's just a string message")
541631
pass
542632

543633
message = cast(Dict[str, Any], message)
@@ -570,6 +660,9 @@ def get_message_payload(
570660
elif subject.startswith("Savings Plans Coverage Alert:"):
571661
parsedMsg = parse_aws_savings_plan(subject=subject, message=str(message))
572662

663+
elif subject.startswith("AWS Cost Management:"):
664+
parsedMsg = parse_cost_anomaly(message=message)
665+
573666
else:
574667
parsedMsg = {
575668
"action": AwsAction.UNKNOWN.value,
@@ -612,7 +705,7 @@ def parse_sns(
612705

613706
payload = renderer.payload(
614707
parsedMessage=parserResults.parsedMsg,
615-
originalMessage=message,
708+
originalMessage=parserResults.originalMsg,
616709
subject=subject,
617710
)
618711
response = vendor_send_to_function(payload=payload)

0 commit comments

Comments
 (0)