Skip to content

Commit 98efe45

Browse files
authored
[gcp][feat] Add SCC service collection (#2291)
1 parent 48a3e69 commit 98efe45

File tree

7 files changed

+286
-2
lines changed

7 files changed

+286
-2
lines changed

plugins/gcp/fix_plugin_gcp/collector.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
compute,
1010
container,
1111
billing,
12+
scc,
1213
sqladmin,
1314
storage,
1415
aiplatform,
@@ -38,6 +39,7 @@
3839
+ filestore.resources
3940
+ cloudfunctions.resources
4041
+ pubsub.resources
42+
+ scc.resources
4143
)
4244

4345

@@ -134,6 +136,10 @@ def get_last_run() -> Optional[datetime]:
134136
global_builder.submit_work(self.collect_region, global_builder.for_region(region))
135137
global_builder.executor.wait_for_submitted_work()
136138

139+
# call all registered after collect hooks
140+
for after_collect in global_builder.after_collect_actions:
141+
after_collect()
142+
137143
self.error_accumulator.report_all(global_builder.core_feedback)
138144

139145
if global_builder.config.collect_usage_metrics:

plugins/gcp/fix_plugin_gcp/resources/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def __init__(
9494
last_run_started_at: Optional[datetime] = None,
9595
graph_nodes_access: Optional[Lock] = None,
9696
graph_edges_access: Optional[Lock] = None,
97+
after_collect_actions: Optional[List[Callable[[], Any]]] = None,
9798
) -> None:
9899
self.graph = graph
99100
self.cloud = cloud
@@ -113,6 +114,7 @@ def __init__(
113114
self.zone_by_name: Dict[str, GcpZone] = {}
114115
self.graph_nodes_access = graph_nodes_access or Lock()
115116
self.graph_edges_access = graph_edges_access or Lock()
117+
self.after_collect_actions = after_collect_actions if after_collect_actions is not None else []
116118

117119
if last_run_started_at:
118120
now = utc()
@@ -349,6 +351,7 @@ def for_region(self, region: GcpRegion) -> GraphBuilder:
349351
self.last_run_started_at,
350352
self.graph_nodes_access,
351353
self.graph_edges_access,
354+
after_collect_actions=self.after_collect_actions,
352355
)
353356

354357

plugins/gcp/fix_plugin_gcp/resources/compute.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5419,7 +5419,7 @@ class GcpNotificationEndpointGrpcSettings:
54195419

54205420

54215421
@define(eq=False, slots=False)
5422-
class GcpNotificationEndpoint(GcpResource):
5422+
class GcpNotificationEndpoint(GcpResource, PhantomBaseResource):
54235423
kind: ClassVar[str] = "gcp_notification_endpoint"
54245424
_kind_display: ClassVar[str] = "GCP Notification Endpoint"
54255425
_kind_description: ClassVar[str] = "GCP Notification Endpoint is a Google Cloud Platform service that receives and processes notifications from various GCP resources. It acts as a central point for collecting and routing alerts, updates, and event data. Users can configure endpoints to direct notifications to specific destinations like email, SMS, or third-party applications for monitoring and response purposes." # fmt: skip
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
from datetime import datetime
2+
from functools import partial
3+
from typing import ClassVar, Dict, Optional, List, Tuple, Type, Any
4+
5+
from attr import define, field
6+
7+
from fix_plugin_gcp.gcp_client import GcpApiSpec
8+
from fix_plugin_gcp.resources.base import (
9+
GcpRegion,
10+
GcpResource,
11+
GcpZone,
12+
GraphBuilder,
13+
GcpErrorHandler,
14+
GcpProject,
15+
GcpExpectedErrorCodes,
16+
)
17+
from fixlib.baseresources import SEVERITY_MAPPING, Finding, Severity
18+
from fixlib.json_bender import Bender, S, Bend
19+
from fixlib.types import Json
20+
21+
22+
@define(eq=False, slots=False)
23+
class GcpSourceProperties:
24+
kind: ClassVar[str] = "gcp_source_properties"
25+
mapping: ClassVar[Dict[str, Bender]] = {
26+
"recommendation": S("Recommendation"),
27+
"explanation": S("Explanation"),
28+
}
29+
recommendation: Optional[str] = field(default=None)
30+
explanation: Optional[str] = field(default=None)
31+
32+
33+
@define(eq=False, slots=False)
34+
class GcpFinding:
35+
kind: ClassVar[str] = "gcp_finding"
36+
mapping: ClassVar[Dict[str, Bender]] = {
37+
"severity": S("severity"),
38+
"source_properties": S("sourceProperties", default={}) >> Bend(GcpSourceProperties.mapping),
39+
"description": S("description"),
40+
"event_time": S("eventTime"),
41+
"parent_display_name": S("parentDisplayName"),
42+
# "access": S("access", default={}) >> Bend(GcpAccess.mapping),
43+
# "application": S("application", default={}) >> Bend(GcpApplication.mapping),
44+
# "attack_exposure": S("attackExposure", default={}) >> Bend(GcpAttackExposure.mapping),
45+
# "backup_disaster_recovery": S("backupDisasterRecovery", default={}) >> Bend(GcpBackupDisasterRecovery.mapping),
46+
# "canonical_name": S("canonicalName"),
47+
# "category": S("category"),
48+
# "cloud_armor": S("cloudArmor", default={}) >> Bend(GcpCloudArmor.mapping),
49+
# "cloud_dlp_data_profile": S("cloudDlpDataProfile", default={}) >> Bend(GcpCloudDlpDataProfile.mapping),
50+
# "cloud_dlp_inspection": S("cloudDlpInspection", default={}) >> Bend(GcpCloudDlpInspection.mapping),
51+
# "compliances": S("compliances", default=[]) >> ForallBend(GcpCompliance.mapping),
52+
# "connections": S("connections", default=[]) >> ForallBend(GcpConnection.mapping),
53+
# "contacts": S("contacts", default={}) >> MapDict(value_bender=Bend(GcpContactDetails.mapping)),
54+
# "containers": S("containers", default=[]) >> ForallBend(GcpContainer.mapping),
55+
# "create_time": S("createTime"),
56+
# "data_access_events": S("dataAccessEvents", default=[]) >> ForallBend(GcpDataAccessEvent.mapping),
57+
# "data_flow_events": S("dataFlowEvents", default=[]) >> ForallBend(GcpDataFlowEvent.mapping),
58+
# "database": S("database", default={}) >> Bend(GcpDatabase.mapping),
59+
# "exfiltration": S("exfiltration", default={}) >> Bend(GcpExfiltration.mapping),
60+
# "external_systems": S("externalSystems", default={})
61+
# >> MapDict(value_bender=Bend(GcpGoogleCloudSecuritycenterV1ExternalSystem.mapping)),
62+
# "external_uri": S("externalUri"),
63+
# "files": S("files", default=[]) >> ForallBend(GcpFile.mapping),
64+
# "finding_class": S("findingClass"),
65+
# "group_memberships": S("groupMemberships", default=[]) >> ForallBend(GcpGroupMembership.mapping),
66+
# "iam_bindings": S("iamBindings", default=[]) >> ForallBend(GcpIamBinding.mapping),
67+
# "indicator": S("indicator", default={}) >> Bend(GcpIndicator.mapping),
68+
# "kernel_rootkit": S("kernelRootkit", default={}) >> Bend(GcpKernelRootkit.mapping),
69+
# "kubernetes": S("kubernetes", default={}) >> Bend(GcpKubernetes.mapping),
70+
# "load_balancers": S("loadBalancers", default=[]) >> ForallBend(S("name")),
71+
# "log_entries": S("logEntries", default=[]) >> ForallBend(GcpLogEntry.mapping),
72+
# "mitre_attack": S("mitreAttack", default={}) >> Bend(GcpMitreAttack.mapping),
73+
# "module_name": S("moduleName"),
74+
# "mute": S("mute"),
75+
# "mute_info": S("muteInfo", default={}) >> Bend(GcpMuteInfo.mapping),
76+
# "mute_initiator": S("muteInitiator"),
77+
# "mute_update_time": S("muteUpdateTime"),
78+
# "name": S("name"),
79+
# "next_steps": S("nextSteps"),
80+
# "notebook": S("notebook", default={}) >> Bend(GcpNotebook.mapping),
81+
# "org_policies": S("orgPolicies", default=[]) >> ForallBend(S("name")),
82+
# "parent": S("parent"),
83+
# "processes": S("processes", default=[]) >> ForallBend(GcpProcess.mapping),
84+
# "resource_name": S("resourceName"),
85+
# "security_marks": S("securityMarks", default={}) >> Bend(GcpSecurityMarks.mapping),
86+
# "security_posture": S("securityPosture", default={}) >> Bend(GcpSecurityPosture.mapping),
87+
# "state": S("state"),
88+
# "toxic_combination": S("toxicCombination", default={}) >> Bend(GcpToxicCombination.mapping),
89+
# "vulnerability": S("vulnerability", default={}) >> Bend(GcpVulnerability.mapping),
90+
}
91+
description: Optional[str] = field(default=None)
92+
event_time: Optional[datetime] = field(default=None)
93+
parent_display_name: Optional[str] = field(default=None)
94+
severity: Optional[str] = field(default=None)
95+
source_properties: Optional[GcpSourceProperties] = field(default=None)
96+
97+
98+
@define(eq=False, slots=False)
99+
class GcpFindingResource:
100+
kind: ClassVar[str] = "gcp_fingding_resource"
101+
mapping: ClassVar[Dict[str, Bender]] = {
102+
"cloud_provider": S("cloudProvider"),
103+
"display_name": S("displayName"),
104+
"location": S("location"),
105+
# "aws_metadata": S("awsMetadata", default={}) >> Bend(GcpAwsMetadata.mapping),
106+
# "azure_metadata": S("azureMetadata", default={}) >> Bend(GcpAzureMetadata.mapping),
107+
# "folders": S("folders", default=[]) >> ForallBend(GcpFolder.mapping),
108+
# "name": S("name"),
109+
# "organization": S("organization"),
110+
# "parent_display_name": S("parentDisplayName"),
111+
# "parent_name": S("parentName"),
112+
# "project_display_name": S("projectDisplayName"),
113+
# "project_name": S("projectName"),
114+
# "resource_path": S("resourcePath", default={}) >> Bend(GcpResourcePath.mapping),
115+
# "resource_path_string": S("resourcePathString"),
116+
# "service": S("service"),
117+
# "type": S("type"),
118+
}
119+
cloud_provider: Optional[str] = field(default=None)
120+
display_name: Optional[str] = field(default=None)
121+
location: Optional[str] = field(default=None)
122+
123+
124+
@define(eq=False, slots=False)
125+
class GcpSccFinding(GcpResource):
126+
kind: ClassVar[str] = "gcp_scc_finding"
127+
_model_export: ClassVar[bool] = False
128+
api_spec: ClassVar[GcpApiSpec] = GcpApiSpec(
129+
service="securitycenter",
130+
version="v1",
131+
accessors=["projects", "sources", "findings"],
132+
action="list",
133+
request_parameter={"parent": "projects/{project}/sources/-", "filter": 'state="ACTIVE"'},
134+
request_parameter_in={"project"},
135+
response_path="listFindingsResults",
136+
response_regional_sub_path=None,
137+
)
138+
mapping: ClassVar[Dict[str, Bender]] = {
139+
"id": S("finding", "name"),
140+
"tags": S("labels", default={}),
141+
"name": S("finding", "name"),
142+
"ctime": S("creationTimestamp"),
143+
"finding_information": S("finding", default={}) >> Bend(GcpFinding.mapping),
144+
"resource_information": S("resource", default={}) >> Bend(GcpFindingResource.mapping),
145+
"state_change": S("stateChange"),
146+
}
147+
finding_information: Optional[GcpFinding] = field(default=None)
148+
resource_information: Optional[GcpFindingResource] = field(default=None)
149+
state_change: Optional[str] = field(default=None)
150+
151+
def parse_finding(self, source: Json) -> Optional[Finding]:
152+
if finding := self.finding_information:
153+
description = finding.description
154+
if finding.source_properties:
155+
remediation = finding.source_properties.recommendation
156+
title = finding.source_properties.explanation or "unknown"
157+
else:
158+
remediation = None
159+
title = "unknown"
160+
source_finding = source.get("finding", {})
161+
source_resource = source.get("resource", {})
162+
details = source_finding.get("sourceProperties", {})
163+
aws_metadata = source_resource.get("awsMetadata", {})
164+
azure_metadata = source_resource.get("azureMetadata", {})
165+
severity = SEVERITY_MAPPING.get(finding.severity or "") or Severity.medium
166+
return Finding(
167+
title, severity, description, remediation, finding.event_time, details | aws_metadata | azure_metadata
168+
)
169+
return None
170+
171+
@classmethod
172+
def collect_resources(cls, builder: GraphBuilder, **kwargs: Any) -> List[GcpResource]:
173+
def add_finding(
174+
provider: str, finding: Finding, clazz: Optional[Type[GcpResource]] = None, **node: Any
175+
) -> None:
176+
if resource := builder.node(clazz=clazz or GcpResource, **node):
177+
resource.add_finding(provider, finding)
178+
179+
if spec := cls.api_spec:
180+
with GcpErrorHandler(
181+
spec.action,
182+
builder.error_accumulator,
183+
spec.service,
184+
builder.region.safe_name if builder.region else None,
185+
GcpExpectedErrorCodes,
186+
f" in {builder.project.id} kind {cls.kind}",
187+
):
188+
for item in builder.client.list(spec, **kwargs):
189+
if finding := GcpSccFinding.from_api(item, builder):
190+
if (ri := finding.resource_information) and (r_name := ri.display_name):
191+
provider = ri.cloud_provider or "google_cloud_scc"
192+
parsed_finding = finding.parse_finding(item)
193+
if not parsed_finding:
194+
continue
195+
if r_name == builder.project.id and ri.location is None:
196+
builder.after_collect_actions.append(
197+
partial(
198+
add_finding,
199+
provider.lower(),
200+
parsed_finding,
201+
GcpProject,
202+
id=r_name,
203+
)
204+
)
205+
206+
def resolve_location(
207+
builder: GraphBuilder, location: str
208+
) -> Tuple[Optional[GcpZone], Optional[GcpRegion]]:
209+
zone = builder.zone_by_name.get(location)
210+
region = builder.region_by_name.get(location)
211+
return zone, region
212+
213+
if ri.location:
214+
zone, region = resolve_location(builder, ri.location)
215+
if zone:
216+
builder.after_collect_actions.append(
217+
partial(
218+
add_finding,
219+
provider.lower(),
220+
parsed_finding,
221+
GcpResource,
222+
id=r_name,
223+
_zone=zone,
224+
)
225+
)
226+
elif region:
227+
builder.after_collect_actions.append(
228+
partial(
229+
add_finding,
230+
provider.lower(),
231+
parsed_finding,
232+
GcpResource,
233+
id=r_name,
234+
_region=region,
235+
)
236+
)
237+
return []
238+
239+
240+
resources: List[Type[GcpResource]] = [GcpSccFinding]

plugins/gcp/test/test_collector.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ def all_base_classes(cls: Type[Any]) -> Set[Type[Any]]:
9090
expected_declared_properties = ["kind", "_kind_display"]
9191
expected_props_in_hierarchy = ["_kind_service", "_metadata"]
9292
for rc in all_resources:
93+
if not rc._model_export:
94+
continue
9395
for prop in expected_declared_properties:
9496
assert prop in rc.__dict__, f"{rc.__name__} missing {prop}"
9597
with_bases = (all_base_classes(rc) | {rc}) - {GcpResource, BaseResource}

plugins/gcp/test/test_scc.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from functools import partial
2+
from typing import Any
3+
4+
from fix_plugin_gcp.resources.base import GraphBuilder
5+
from fix_plugin_gcp.resources.compute import GcpFirewall
6+
from fix_plugin_gcp.resources.scc import GcpSccFinding
7+
from .random_client import roundtrip
8+
9+
10+
class DefaultDict(dict): # type: ignore
11+
def __init__(self, default_value: Any, *args: Any, **kwargs: Any) -> None:
12+
super().__init__(*args, **kwargs)
13+
self.default_value = default_value
14+
15+
def get(self, key: str, default: Any = None) -> Any:
16+
if key in self:
17+
return super().get(key, default)
18+
return self.default_value
19+
20+
21+
def test_gcp_scc_findings(random_builder: GraphBuilder) -> None:
22+
firewall = roundtrip(GcpFirewall, random_builder)
23+
# for random location name we will use the default global location
24+
random_builder.region_by_name = DefaultDict(random_builder.fallback_global_region)
25+
GcpSccFinding.collect_resources(random_builder)
26+
27+
partial(random_builder.after_collect_actions[0], id=firewall.id)() # type: ignore
28+
29+
assert len(firewall._assessments) > 0
30+
assert len(firewall._assessments[0].findings) > 0
31+
assert firewall._assessments[0].findings[0].severity is not None

plugins/gcp/tools/model_gen.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,7 @@ def generate_test_classes() -> None:
518518
},
519519
"firestore": {"parent": "projects/{project_id}/databases/{database_id}/documents", "collectionId": "", "name": ""},
520520
"file": {"name": "", "parent": "projects/{projectId}/locations/-"},
521+
"securitycenter": {"parent": "projects/{projectId}", "name": ""},
521522
"pubsub": {"project": "projects/{project}", "parent": ""},
522523
}
523524

@@ -532,7 +533,8 @@ def generate_test_classes() -> None:
532533
# ("aiplatform", "v1", "", []),
533534
# ("firestore", "v1", "", []),
534535
# ("cloudfunctions", "v2", "", []),
535-
# ("file", "v1", "", []),
536+
# # ("file", "v1", "", []),
537+
# ("securitycenter", "v1", "", []),
536538
("pubsub", "v1", "", [])
537539
]
538540

0 commit comments

Comments
 (0)