Skip to content

Commit 2cccb44

Browse files
authored
[aws][feat] Reimplement SSM Compliance resource collection (#2280)
1 parent aeeee14 commit 2cccb44

File tree

6 files changed

+125
-47
lines changed

6 files changed

+125
-47
lines changed

plugins/aws/fix_plugin_aws/resource/s3.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from functools import partial
12
import logging
23
from collections import defaultdict
34
from datetime import timedelta
@@ -363,9 +364,22 @@ def _get_tags(self, client: AwsClient) -> Dict[str, str]:
363364
return tags_as_dict(tag_list) # type: ignore
364365

365366
def collect_usage_metrics(self, builder: GraphBuilder) -> List[AwsCloudwatchQuery]:
367+
def _calculate_total_size(bucket_instance: AwsS3Bucket) -> None:
368+
# Calculate the total bucket size for each bucket by summing up the sizes of all storage types
369+
bucket_size: Dict[str, float] = defaultdict(float)
370+
for metric_name, metric_values in bucket_instance._resource_usage.items():
371+
if metric_name.endswith("_bucket_size_bytes"):
372+
for name, value in metric_values.items():
373+
bucket_size[name] += value
374+
if bucket_size:
375+
bucket_instance._resource_usage["bucket_size_bytes"] = dict(bucket_size)
376+
366377
# Filter out metrics with the 'aws-controltower' dimension value
367378
if "aws-controltower" in self.safe_name:
368379
return []
380+
381+
# calculate all bucket sizes after usage metrics collection
382+
builder.after_collect_actions.append(partial(_calculate_total_size, self))
369383
storage_types = {
370384
"StandardStorage": "standard_storage",
371385
"IntelligentTieringStorage": "intelligent_tiering_storage",
@@ -415,16 +429,6 @@ def collect_usage_metrics(self, builder: GraphBuilder) -> List[AwsCloudwatchQuer
415429
)
416430
return queries
417431

418-
def complete_graph(self, builder: GraphBuilder, source: Json) -> None:
419-
# Calculate the total bucket size for each bucket by summing up the sizes of all storage types
420-
bucket_size: Dict[str, float] = defaultdict(float)
421-
for metric_name, metric_values in self._resource_usage.items():
422-
if metric_name.endswith("_bucket_size_bytes"):
423-
for name, value in metric_values.items():
424-
bucket_size[name] += value
425-
if bucket_size:
426-
self._resource_usage["bucket_size_bytes"] = dict(bucket_size)
427-
428432
def update_resource_tag(self, client: AwsClient, key: str, value: str) -> bool:
429433
tags = self._get_tags(client)
430434
tags[key] = value

plugins/aws/fix_plugin_aws/resource/ssm.py

Lines changed: 78 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import json
1+
from functools import partial
2+
from json import loads as json_loads
23
import logging
34
from datetime import datetime
45
from typing import ClassVar, Dict, Optional, List, Type, Any
@@ -12,9 +13,10 @@
1213
from fix_plugin_aws.resource.ec2 import AwsEc2Instance
1314
from fix_plugin_aws.resource.s3 import AwsS3Bucket
1415
from fix_plugin_aws.utils import ToDict
15-
from fixlib.baseresources import ModelReference
16-
from fixlib.json_bender import Bender, S, Bend, AsDateString, ForallBend, K
16+
from fixlib.baseresources import SEVERITY_MAPPING, Finding, ModelReference, PhantomBaseResource, Severity
17+
from fixlib.json_bender import Bender, S, Bend, AsDateString, ForallBend
1718
from fixlib.types import Json
19+
from fixlib.utils import chunks
1820

1921
log = logging.getLogger("fix.plugins.aws")
2022
service_name = "ssm"
@@ -239,7 +241,7 @@ def collect_document(name: str) -> None:
239241
and (instance := cls.from_api(js, builder))
240242
):
241243
if content_format == "JSON":
242-
instance.content = json.loads(content)
244+
instance.content = json_loads(content)
243245
elif content_format == "YAML":
244246
instance.content = yaml.safe_load(content)
245247
else:
@@ -341,7 +343,7 @@ class AwsSSMNonCompliantSummary:
341343
severity_summary: Optional[AwsSSMSeveritySummary] = field(default=None, metadata={"description": "A summary of the non-compliance severity by compliance type"}) # fmt: skip
342344

343345

344-
ResourceTypeLookup = {
346+
ResourceTypeLookup: Dict[str, Type[AwsResource]] = {
345347
"ManagedInstance": AwsEc2Instance,
346348
"AWS::EC2::Instance": AwsEc2Instance,
347349
"AWS::DynamoDB::Table": AwsDynamoDbTable,
@@ -351,45 +353,88 @@ class AwsSSMNonCompliantSummary:
351353

352354

353355
@define(eq=False, slots=False)
354-
class AwsSSMResourceCompliance(AwsResource):
356+
class AwsSSMResourceCompliance(AwsResource, PhantomBaseResource):
355357
kind: ClassVar[str] = "aws_ssm_resource_compliance"
356-
_kind_display: ClassVar[str] = "AWS SSM Resource Compliance"
357-
_kind_description: ClassVar[str] = "AWS SSM Resource Compliance is a feature within AWS Systems Manager that evaluates and reports on the compliance status of AWS resources. It checks resources against predefined or custom rules, identifying non-compliant configurations and security issues. Users can view compliance data, generate reports, and take corrective actions to maintain resource adherence to organizational standards and best practices." # fmt: skip
358-
_docs_url: ClassVar[str] = (
359-
"https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-compliance-about.html"
360-
)
361-
_kind_service: ClassVar[Optional[str]] = service_name
362-
_metadata: ClassVar[Dict[str, Any]] = {"icon": "resource", "group": "management"}
363-
_aws_metadata: ClassVar[Dict[str, Any]] = {"arn_tpl": "arn:{partition}:ssm:{region}:{account}:resource-compliance/{id}"} # fmt: skip
358+
_model_export: ClassVar[bool] = False # do not export this class, since there will be no instances of it
364359
api_spec: ClassVar[AwsApiSpec] = AwsApiSpec(
365-
"ssm", "list-resource-compliance-summaries", "ResourceComplianceSummaryItems"
360+
"ssm",
361+
"list-resource-compliance-summaries",
362+
"ResourceComplianceSummaryItems",
363+
{"Filters": [{"Key": "Status", "Values": ["COMPLIANT"], "Type": "EQUAL"}]},
366364
)
367-
_reference_kinds: ClassVar[ModelReference] = {
368-
"successors": {"default": ["aws_ec2_instance", "aws_dynamodb_table", "aws_s3_bucket", "aws_ssm_document"]}
369-
}
370365
mapping: ClassVar[Dict[str, Bender]] = {
371-
"id": S("ComplianceType") + K("_") + S("ResourceType") + K("_") + S("ResourceId"),
366+
"id": S("Id"),
367+
"name": S("title"),
372368
"compliance_type": S("ComplianceType"),
373369
"resource_type": S("ResourceType"),
374370
"resource_id": S("ResourceId"),
371+
"title": S("Title"),
375372
"status": S("Status"),
376-
"overall_severity": S("OverallSeverity"),
373+
"severity": S("Severity"),
377374
"execution_summary": S("ExecutionSummary") >> Bend(AwsSSMComplianceExecutionSummary.mapping),
378-
"compliant_summary": S("CompliantSummary") >> Bend(AwsSSMCompliantSummary.mapping),
379-
"non_compliant_summary": S("NonCompliantSummary") >> Bend(AwsSSMNonCompliantSummary.mapping),
375+
"compliance_details": S("Details"),
380376
}
381-
compliance_type: Optional[str] = field(default=None, metadata={"description": "The compliance type."}) # fmt: skip
382-
resource_type: Optional[str] = field(default=None, metadata={"description": "The resource type."}) # fmt: skip
383-
resource_id: Optional[str] = field(default=None, metadata={"description": "The resource ID."}) # fmt: skip
384-
status: Optional[str] = field(default=None, metadata={"description": "The compliance status for the resource."}) # fmt: skip
385-
overall_severity: Optional[str] = field(default=None, metadata={"description": "The highest severity item found for the resource. The resource is compliant for this item."}) # fmt: skip
386-
execution_summary: Optional[AwsSSMComplianceExecutionSummary] = field(default=None, metadata={"ignore_history": True, "description": "Information about the execution."}) # fmt: skip
387-
compliant_summary: Optional[AwsSSMCompliantSummary] = field(default=None, metadata={"description": "A list of items that are compliant for the resource."}) # fmt: skip
388-
non_compliant_summary: Optional[AwsSSMNonCompliantSummary] = field(default=None, metadata={"description": "A list of items that aren't compliant for the resource."}) # fmt: skip
377+
compliance_type: Optional[str] = field(default=None, metadata={"description": "The compliance type. For example, Association (for a State Manager association), Patch, or Custom:string are all valid compliance types."}) # fmt: skip
378+
resource_type: Optional[str] = field(default=None, metadata={"description": "The type of resource. ManagedInstance is currently the only supported resource type."}) # fmt: skip
379+
resource_id: Optional[str] = field(default=None, metadata={"description": "An ID for the resource. For a managed node, this is the node ID."}) # fmt: skip
380+
title: Optional[str] = field(default=None, metadata={"description": "A title for the compliance item. For example, if the compliance item is a Windows patch, the title could be the title of the KB article for the patch; for example: Security Update for Active Directory Federation Services."}) # fmt: skip
381+
status: Optional[str] = field(default=None, metadata={"description": "The status of the compliance item. An item is either COMPLIANT, NON_COMPLIANT, or an empty string (for Windows patches that aren't applicable)."}) # fmt: skip
382+
severity: Optional[str] = field(default=None, metadata={"description": "The severity of the compliance status. Severity can be one of the following: Critical, High, Medium, Low, Informational, Unspecified."}) # fmt: skip
383+
execution_summary: Optional[AwsSSMComplianceExecutionSummary] = field(default=None, metadata={"description": "A summary for the compliance item. The summary includes an execution ID, the execution type (for example, command), and the execution time."}) # fmt: skip
384+
compliance_details: Optional[Dict[str, str]] = field(default=None, metadata={"description": "A Key:Value tag combination for the compliance item."}) # fmt: skip
385+
386+
def parse_finding(self) -> Finding:
387+
title = self.title or ""
388+
severity = SEVERITY_MAPPING.get(self.severity or "", Severity.medium)
389+
details = self.compliance_details
390+
if self.execution_summary:
391+
updated_at = self.execution_summary.execution_time
392+
else:
393+
updated_at = None
394+
return Finding(title, severity, None, None, updated_at, details)
389395

390-
def connect_in_graph(self, builder: GraphBuilder, source: Json) -> None:
391-
if (rt := self.resource_type) and (rid := self.resource_id) and (clazz := ResourceTypeLookup.get(rt)):
392-
builder.add_edge(self, clazz=clazz, id=rid)
396+
@classmethod
397+
def collect(cls, json: List[Json], builder: GraphBuilder) -> None:
398+
def add_finding(
399+
provider: str, finding: Finding, clazz: Optional[Type[AwsResource]] = None, **node: Any
400+
) -> None:
401+
if resource := builder.node(clazz=clazz, **node):
402+
resource.add_finding(provider, finding)
403+
404+
def collect_compliance_items(jsons: List[Json]) -> None:
405+
spec = AwsApiSpec("ssm", "list-compliance-items", "ComplianceItems")
406+
compliance_ids = [item["ResourceId"] for item in jsons]
407+
for result in builder.client.list(
408+
aws_service="ssm",
409+
action=spec.api_action,
410+
result_name=spec.result_property,
411+
expected_errors=spec.expected_errors,
412+
ResourceIds=compliance_ids,
413+
):
414+
if finding := AwsSSMResourceCompliance.from_api(result, builder):
415+
if (
416+
(rt := finding.resource_type)
417+
and (rid := finding.resource_id)
418+
and (clazz := ResourceTypeLookup.get(rt))
419+
):
420+
# append the finding when all resources have been collected
421+
builder.after_collect_actions.append(
422+
partial(
423+
add_finding,
424+
"amazon_ssm_compliance",
425+
finding.parse_finding(),
426+
clazz,
427+
id=rid,
428+
)
429+
)
430+
431+
# we can request only 40 items per request
432+
for jsons in chunks(json, 39):
433+
builder.submit_work("ssm", collect_compliance_items, jsons)
434+
435+
@classmethod
436+
def called_collect_apis(cls) -> List[AwsApiSpec]:
437+
return [cls.api_spec, AwsApiSpec("ssm", "list-compliance-items", "ComplianceItems")]
393438

394439

395440
resources: List[Type[AwsResource]] = [AwsSSMInstance, AwsSSMDocument, AwsSSMResourceCompliance]

plugins/aws/test/collector_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ def count_kind(clazz: Type[AwsResource]) -> int:
3737
# make sure all threads have been joined
3838
assert len(threading.enumerate()) == 1
3939
# ensure the correct number of nodes and edges
40-
assert count_kind(AwsResource) == 260
41-
assert len(account_collector.graph.edges) == 574
40+
assert count_kind(AwsResource) == 257
41+
assert len(account_collector.graph.edges) == 571
4242
assert len(account_collector.graph.deferred_edges) == 2
4343
for node in account_collector.graph.nodes:
4444
if isinstance(node, AwsRegion):
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"ComplianceItems": [
3+
{
4+
"ComplianceType": "Association",
5+
"ResourceType": "ManagedInstance",
6+
"ResourceId": "i-1",
7+
"Id": "SSM-Association-1",
8+
"Title": "State Manager Association Compliance",
9+
"Status": "NON_COMPLIANT",
10+
"Severity": "HIGH",
11+
"ExecutionSummary": {
12+
"ExecutionTime": "2024-10-01T12:34:56Z",
13+
"ExecutionId": "xyz5678-execution-id",
14+
"ExecutionType": "Association"
15+
},
16+
"Details": {
17+
"LastExecutionStatus": "Failed",
18+
"ErrorDetails": "Failed to apply association"
19+
}
20+
}
21+
],
22+
"NextToken": "next-token-value"
23+
}

plugins/aws/test/resources/files/ssm/list-resource-compliance-summaries.json renamed to plugins/aws/test/resources/files/ssm/list-resource-compliance-summaries__Status_COMPLIANT_EQUAL.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,4 @@
104104
}
105105
],
106106
"NextToken": "foo"
107-
}
107+
}

plugins/aws/test/resources/ssm_test.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from fix_plugin_aws.resource.ec2 import AwsEc2Instance
12
from fix_plugin_aws.resource.ssm import (
23
AwsSSMInstance,
34
AwsSSMDocument,
@@ -6,14 +7,19 @@
67
)
78
from test.resources import round_trip_for
89

10+
from fixlib.baseresources import Severity
11+
912

1013
def test_instances() -> None:
1114
first, builder = round_trip_for(AwsSSMInstance)
1215
assert len(builder.resources_of(AwsSSMInstance)) == 2
1316

1417

1518
def test_resource_compliance() -> None:
16-
round_trip_for(AwsSSMResourceCompliance)
19+
collected, _ = round_trip_for(AwsEc2Instance, region_name="global", collect_also=[AwsSSMResourceCompliance])
20+
asseessments = collected._assessments
21+
assert asseessments[0].findings[0].title == "State Manager Association Compliance"
22+
assert asseessments[0].findings[0].severity == Severity.high
1723

1824

1925
def test_documents() -> None:

0 commit comments

Comments
 (0)