Skip to content

Commit 309a634

Browse files
1101-1aquamatthias
andauthored
[azure][feat] Update security assessments collection (#2266)
Co-authored-by: Matthias Veit <matthias_veit@yahoo.de>
1 parent c22f979 commit 309a634

File tree

7 files changed

+95
-28
lines changed

7 files changed

+95
-28
lines changed

plugins/azure/fix_plugin_azure/collector.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ def get_last_run() -> Optional[datetime]:
155155
self.collect_with(builder, locations)
156156
queue.wait_for_submitted_work()
157157

158+
# call all registered after collect hooks
159+
for after_collect in builder.after_collect_actions:
160+
after_collect()
161+
158162
# connect nodes
159163
log.info(f"[Azure:{self.account.safe_name}] Connect resources and create edges.")
160164
for node, data in list(self.graph.nodes(data=True)):

plugins/azure/fix_plugin_azure/resource/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,7 @@ def __init__(
782782
location: Optional[BaseRegion] = None,
783783
graph_access_lock: Optional[RWLock] = None,
784784
last_run_started_at: Optional[datetime] = None,
785+
after_collect_actions: Optional[List[Callable[[], Any]]] = None,
785786
) -> None:
786787
self.graph = graph
787788
self.cloud = cloud
@@ -796,6 +797,7 @@ def __init__(
796797
self.config = config
797798
self.last_run_started_at = last_run_started_at
798799
self.created_at = utc()
800+
self.after_collect_actions = after_collect_actions if after_collect_actions is not None else []
799801

800802
if last_run_started_at:
801803
now = utc()
@@ -1002,6 +1004,7 @@ def with_location(self, location: BaseRegion) -> GraphBuilder:
10021004
graph_access_lock=self.graph_access_lock,
10031005
config=self.config,
10041006
last_run_started_at=self.last_run_started_at,
1007+
after_collect_actions=self.after_collect_actions,
10051008
)
10061009

10071010

plugins/azure/fix_plugin_azure/resource/network.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5274,6 +5274,7 @@ class AzureNetworkVirtualNetwork(MicrosoftResource, BaseNetwork):
52745274
"virtual_network_peerings": S("properties", "virtualNetworkPeerings")
52755275
>> ForallBend(AzureVirtualNetworkPeering.mapping),
52765276
"location": S("location"),
5277+
"type": S("type"),
52775278
}
52785279
address_space: Optional[AzureAddressSpace] = field(default=None, metadata={'description': 'AddressSpace contains an array of IP address ranges that can be used by subnets of the virtual network.'}) # fmt: skip
52795280
bgp_communities: Optional[AzureVirtualNetworkBgpCommunities] = field(default=None, metadata={'description': 'Bgp Communities sent over ExpressRoute with each route corresponding to a prefix in this VNET.'}) # fmt: skip
@@ -5290,6 +5291,7 @@ class AzureNetworkVirtualNetwork(MicrosoftResource, BaseNetwork):
52905291
_subnet_ids: Optional[List[str]] = field(default=None, metadata={'description': 'A list of subnets in a Virtual Network.'}) # fmt: skip
52915292
virtual_network_peerings: Optional[List[AzureVirtualNetworkPeering]] = field(default=None, metadata={'description': 'A list of peerings in a Virtual Network.'}) # fmt: skip
52925293
location: Optional[str] = field(default=None, metadata={"description": "Resource location."})
5294+
type: Optional[str] = field(default=None, metadata={"description": "Type of the resource."})
52935295

52945296
def post_process(self, graph_builder: GraphBuilder, source: Json) -> None:
52955297
def collect_subnets() -> None:

plugins/azure/fix_plugin_azure/resource/security.py

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
from datetime import datetime
2+
from functools import partial
3+
import logging
24
from typing import ClassVar, Dict, Optional, List, Any, Type
35

46
from attr import define, field
57

68
from fix_plugin_azure.azure_client import AzureResourceSpec
79
from fix_plugin_azure.resource.base import MicrosoftResource, AzureSystemData, GraphBuilder
8-
from fixlib.baseresources import ModelReference, PhantomBaseResource
10+
from fixlib.baseresources import SEVERITY_MAPPING, Finding, PhantomBaseResource, Severity
911
from fixlib.json_bender import Bender, S, Bend, ForallBend, F
1012
from fixlib.types import Json
1113

1214
service_name = "security"
15+
log = logging.getLogger("fix.plugins.azure")
1316

1417

1518
@define(eq=False, slots=False)
@@ -94,14 +97,7 @@ class AzureAssessmentStatus:
9497
@define(eq=False, slots=False)
9598
class AzureSecurityAssessment(MicrosoftResource, PhantomBaseResource):
9699
kind: ClassVar[str] = "azure_security_assessment"
97-
_kind_display: ClassVar[str] = "Azure Security Assessment"
98-
_kind_service: ClassVar[Optional[str]] = service_name
99-
_kind_description: ClassVar[str] = "Azure Security Assessment is a service that evaluates Azure resources for potential security vulnerabilities and compliance issues. It scans configurations, identifies risks, and provides recommendations to improve security posture. The assessment covers various aspects including network security, data protection, and access control, offering insights to help organizations strengthen their Azure environment's security." # fmt: skip
100-
_docs_url: ClassVar[str] = (
101-
"https://learn.microsoft.com/en-us/azure/defender-for-cloud/secure-score-security-controls"
102-
)
103-
_metadata: ClassVar[Dict[str, Any]] = {"icon": "log", "group": "management"}
104-
_reference_kinds: ClassVar[ModelReference] = {"successors": {"default": [MicrosoftResource.kind]}}
100+
_model_export: ClassVar[bool] = False
105101
api_spec: ClassVar[AzureResourceSpec] = AzureResourceSpec(
106102
service=service_name,
107103
version="2021-06-01",
@@ -118,24 +114,62 @@ class AzureSecurityAssessment(MicrosoftResource, PhantomBaseResource):
118114
"assessment_status": S("properties", "status") >> Bend(AzureAssessmentStatus.mapping),
119115
"resource_source": S("properties", "resourceDetails", "Source"),
120116
"resource_id": S("properties", "resourceDetails", "ResourceId"),
117+
"resource_type": S("properties", "resourceDetails", "ResourceType"),
121118
"additional_date": S("properties", "additionalData"),
122119
"azurePortalUri": S("properties", "links", "azurePortalUri"),
123120
}
124121
assessment_status: Optional[AzureAssessmentStatus] = field(default=None, metadata={'description': 'The result of the assessment'}) # fmt: skip
125122
resource_source: Optional[str] = field(default=None, metadata={'description': 'The source of the resource that the assessment is performed on'}) # fmt: skip
126123
resource_id: Optional[str] = field(default=None, metadata={'description': 'The id of the resource that the assessment is performed on'}) # fmt: skip
124+
resource_type: Optional[str] = field(default=None, metadata={'description': 'The resource type'}) # fmt: skip
127125
additional_data: Optional[Dict[str, Any]] = field(default=None, metadata={'description': 'Additional data for the assessment'}) # fmt: skip
128126
subscription_issue: Optional[bool] = field(default=False, metadata={'description': 'Indicates if the assessment is a subscription issue'}) # fmt: skip
129127

130-
def post_process(self, builder: GraphBuilder, source: Json) -> None:
131-
# mark as subscription issue, when the resource id is the same as the account id
132-
if (rid := self.resource_id) and (sub := self._account):
133-
self.subscription_issue = rid.split("/")[-1] == sub.id
128+
def parse_finding(self, source: Json) -> Finding:
129+
finding_title = self.safe_name
130+
properties = source.get("properties") or {}
131+
if metadata := properties.get("metadata", {}):
132+
finding_severity = SEVERITY_MAPPING.get(metadata.get("severity", "").upper(), Severity.medium)
133+
else:
134+
finding_severity = Severity.medium
135+
if status := self.assessment_status:
136+
description = status.description
137+
updated_at = status.status_change_date
138+
else:
139+
description = None
140+
updated_at = None
141+
details = self.additional_data or {} | properties.get("metadata", {})
142+
return Finding(finding_title, finding_severity, description, None, updated_at, details)
143+
144+
@classmethod
145+
def collect_resources(cls, builder: GraphBuilder, **kwargs: Any) -> List["AzureSecurityAssessment"]:
146+
def add_finding(provider: str, finding: Finding, resource_id: str) -> None:
147+
if resource := builder.node(clazz=MicrosoftResource, id=resource_id):
148+
resource.add_finding(provider, finding)
149+
150+
# Default behavior: in case the class has an ApiSpec, call the api and call collect.
151+
log.debug(f"[Azure:{builder.account.id}] Collecting {cls.__name__} with ({kwargs})")
152+
if spec := cls.api_spec:
153+
try:
154+
for item in builder.client.list(spec, **kwargs):
155+
if finding := AzureSecurityAssessment.from_api(item, builder):
156+
if finding.resource_source == "Azure" and (rid := finding.resource_id):
157+
if finding.resource_type == "subscription":
158+
rid = "/subscriptions/" + rid
159+
builder.after_collect_actions.append(
160+
partial(
161+
add_finding,
162+
"azure_security_assessment",
163+
finding.parse_finding(item),
164+
rid,
165+
)
166+
)
167+
except Exception as e:
168+
msg = f"Error while collecting {cls.__name__} with service {spec.service} and location: {builder.location}: {e}"
169+
builder.core_feedback.info(msg, log)
170+
raise
134171

135-
def connect_in_graph(self, builder: GraphBuilder, source: Json) -> None:
136-
# this will not connect subscription issues.
137-
if self.resource_source == "Azure" and (rid := self.resource_id):
138-
builder.add_edge(self, clazz=MicrosoftResource, id=rid)
172+
return []
139173

140174

141175
@define(eq=False, slots=False)

plugins/azure/test/collector_test.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ def test_collect(
4848
config, Cloud(id="azure"), azure_subscription, credentials, core_feedback, filter_unused_resources=False
4949
)
5050
subscription_collector.collect()
51-
assert len(subscription_collector.graph.nodes) == 889
52-
assert len(subscription_collector.graph.edges) == 1284
51+
assert len(subscription_collector.graph.nodes) == 887
52+
assert len(subscription_collector.graph.edges) == 1282
5353

5454
graph_collector = MicrosoftGraphOrganizationCollector(
5555
config, Cloud(id="azure"), MicrosoftGraphOrganization(id="test", name="test"), credentials, core_feedback
@@ -113,6 +113,8 @@ def all_base_classes(cls: Type[Any]) -> Set[Type[Any]]:
113113
expected_declared_properties = ["kind", "_kind_display"]
114114
expected_props_in_hierarchy = ["_kind_service", "_metadata"]
115115
for rc in all_resources:
116+
if not rc._model_export:
117+
continue
116118
for prop in expected_declared_properties:
117119
assert prop in rc.__dict__, f"{rc.__name__} missing {prop}"
118120
with_bases = (all_base_classes(rc) | {rc}) - {MicrosoftResource, BaseResource}

plugins/azure/test/files/security/assessments.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
"type": "Microsoft.Security/assessments",
77
"properties": {
88
"resourceDetails": {
9-
"source": "Azure",
10-
"id": "/subscriptions/20ff7fc3-e762-44dd-bd96-b71116dcdc23/resourceGroups/myRg/providers/Microsoft.Compute/virtualMachineScaleSets/vmss1"
9+
"Source": "Azure",
10+
"ResourceId": "/subscriptions/{subscription-id}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachineScaleSets/{virtualMachineScaleSetName}"
1111
},
1212
"displayName": "Install endpoint protection solution on virtual machine scale sets",
1313
"status": {
@@ -40,4 +40,4 @@
4040
}
4141
}
4242
]
43-
}
43+
}

plugins/azure/test/security_test.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,39 @@
11
from conftest import roundtrip_check
2-
from fix_plugin_azure.resource.base import GraphBuilder
2+
3+
from fix_plugin_azure.azure_client import MicrosoftClient
4+
from fix_plugin_azure.collector import AzureSubscriptionCollector
5+
from fix_plugin_azure.config import AzureConfig, AzureCredentials
6+
from fix_plugin_azure.resource.base import AzureSubscription, GraphBuilder
7+
from fix_plugin_azure.resource.compute import AzureComputeVirtualMachineScaleSet
38
from fix_plugin_azure.resource.security import (
4-
AzureSecurityAssessment,
59
AzureSecurityPricing,
610
AzureSecurityServerVulnerabilityAssessmentsSetting,
711
AzureSecuritySetting,
812
AzureSecurityAutoProvisioningSetting,
913
)
1014

11-
12-
def test_security_assessment(builder: GraphBuilder) -> None:
13-
collected = roundtrip_check(AzureSecurityAssessment, builder)
14-
assert len(collected) == 2
15+
from fixlib.baseresources import Cloud, Severity
16+
from fixlib.core.actions import CoreFeedback
17+
18+
19+
def test_security_assessment(
20+
config: AzureConfig,
21+
azure_subscription: AzureSubscription,
22+
credentials: AzureCredentials,
23+
core_feedback: CoreFeedback,
24+
azure_client: MicrosoftClient,
25+
) -> None:
26+
subscription_collector = AzureSubscriptionCollector(
27+
config, Cloud(id="azure"), azure_subscription, credentials, core_feedback, filter_unused_resources=False
28+
)
29+
subscription_collector.collect()
30+
instances = list(subscription_collector.graph.search("kind", AzureComputeVirtualMachineScaleSet.kind))
31+
assert instances[0]._assessments[0].provider == "azure_security_assessment"
32+
assert (
33+
instances[0]._assessments[0].findings[0].title
34+
== "Install endpoint protection solution on virtual machine scale sets"
35+
)
36+
assert instances[0]._assessments[0].findings[0].severity == Severity.medium
1537

1638

1739
def test_security_pricing(builder: GraphBuilder) -> None:

0 commit comments

Comments
 (0)