399 lines
14 KiB
Python
399 lines
14 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2023 Google LLC. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Shared utility functions for Cloud SCC findings commands."""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import print_function
|
|
from __future__ import unicode_literals
|
|
|
|
import re
|
|
|
|
from apitools.base.py import encoding
|
|
from googlecloudsdk.api_lib.scc import securitycenter_client
|
|
from googlecloudsdk.command_lib.scc import errors
|
|
from googlecloudsdk.command_lib.scc import util as scc_util
|
|
from googlecloudsdk.core.util import times
|
|
|
|
|
|
def ValidateMutexOnFindingAndSourceAndOrganization(args):
|
|
"""Validates that only a full resource name or split arguments are provided."""
|
|
if "/" in args.finding and (
|
|
args.IsKnownAndSpecified("organization")
|
|
or args.IsKnownAndSpecified("source")
|
|
):
|
|
raise errors.InvalidSCCInputError(
|
|
"Only provide a full resource name "
|
|
"(organizations/123/sources/456/findings/789) or an --organization flag"
|
|
" and --sources flag, not both."
|
|
)
|
|
|
|
|
|
def GetFullFindingName(args, version):
|
|
"""Returns relative resource name for a finding name.
|
|
|
|
Args:
|
|
args: Argument namespace.
|
|
version: Api version.
|
|
|
|
Returns:
|
|
Relative resource name
|
|
if no location is specified the result will be one of the following forms
|
|
`organizations/{organization_id}/sources/{source_id}/finding/{finding_id}`
|
|
`folders/{folders_id}/sources/{source_id}/finding/{finding_id}`
|
|
`projects/{projects_id}/sources/{source_id}/finding/{finding_id}`
|
|
if a location is specified the result will be one of the following forms
|
|
`organizations/{organization_id}/sources/{source_id}/locations/{location_id}/finding/{finding_id}`
|
|
`folders/{folders_id}/sources/{source_id}/locations/{location_id}/finding/{finding_id}`
|
|
`projects/{projects_id}/sources/{source_id}/locations/{location_id}/finding/{finding_id}`
|
|
"""
|
|
resource_pattern = re.compile(
|
|
"(organizations|projects|folders)/.*/sources/[0-9-]+/findings/[a-zA-Z0-9]+$"
|
|
)
|
|
region_resource_pattern = re.compile(
|
|
"(organizations|projects|folders)/.*/sources/[0-9-]+/locations/.*/findings/[a-zA-Z0-9]+$"
|
|
)
|
|
id_pattern = re.compile("^[a-zA-Z0-9]+$")
|
|
if region_resource_pattern.match(args.finding):
|
|
# Handle finding id as full resource name.
|
|
return args.finding
|
|
if resource_pattern.match(args.finding):
|
|
if version == "v2":
|
|
return GetRegionalizedResourceName(args, version)
|
|
return args.finding
|
|
if id_pattern.match(args.finding):
|
|
return f"{GetFullSourceName(args, version)}/findings/{args.finding}"
|
|
|
|
raise errors.InvalidSCCInputError(
|
|
"Finding must match either the full resource name or only the finding id."
|
|
)
|
|
|
|
|
|
def GetFullSourceName(args, version):
|
|
|
|
"""Returns relative resource name for a source from --source argument.
|
|
|
|
Args:
|
|
args: Argument namespace.
|
|
version: Api version.
|
|
|
|
Returns:
|
|
Relative resource name
|
|
if args.source is not provided an exception will be raised
|
|
if no location is specified in argument: sources/{source_id}
|
|
if a location is specified: sources/{source_id}/locations/{location_id}
|
|
"""
|
|
|
|
resource_pattern = re.compile(
|
|
"(organizations|projects|folders)/.*/sources/[0-9-]+"
|
|
)
|
|
region_resource_pattern = re.compile(
|
|
"(organizations|projects|folders)/.*/sources/[0-9-]+/locations/[a-zA-Z0-9-]+$"
|
|
)
|
|
id_pattern = re.compile("[0-9-]+")
|
|
|
|
if not args.source:
|
|
raise errors.InvalidSCCInputError(
|
|
"Finding source must be provided in --source flag or full resource"
|
|
" name."
|
|
)
|
|
|
|
if region_resource_pattern.match(args.source):
|
|
return args.source
|
|
|
|
location = scc_util.ValidateAndGetLocation(args, version)
|
|
if resource_pattern.match(args.source):
|
|
source = args.source
|
|
if version == "v2":
|
|
return f"{source}/locations/{location}"
|
|
return source
|
|
|
|
if (id_pattern.match(args.source) and
|
|
(hasattr(args, "finding") or hasattr(args, "parent"))):
|
|
source = f"{scc_util.GetFindingsParentFromPositionalArguments(args)}/sources/{args.source}"
|
|
if version == "v2":
|
|
return f"{source}/locations/{location}"
|
|
return source
|
|
|
|
if id_pattern.match(args.source):
|
|
source = f"{scc_util.GetParentFromPositionalArguments(args)}/sources/{args.source}"
|
|
if version == "v2":
|
|
return f"{source}/locations/{location}"
|
|
return source
|
|
|
|
raise errors.InvalidSCCInputError(
|
|
"The source must either be the full resource "
|
|
"name or the numeric source ID."
|
|
)
|
|
|
|
|
|
def GetSourceParentFromFindingName(resource_name, version):
|
|
"""Get parent (with source) from Finding name i.e remove 'findings/{finding_name}'.
|
|
|
|
Args:
|
|
resource_name: finding name {parent with source}/findings/{findingID}
|
|
version: API version.
|
|
|
|
Returns:
|
|
The parent with source or parent with source and location
|
|
examples:
|
|
if no location is specified the result will be one of the following forms
|
|
`organizations/{organization_id}/sources/{source_id}`
|
|
`folders/{folders_id}/sources/{source_id}`
|
|
`projects/{projects_id}/sources/{source_id}`
|
|
if a location is specified the result will be one of the following forms
|
|
`organizations/{organization_id}/sources/{source_id}/locations/{location_id}`
|
|
`folders/{folders_id}/sources/{source_id}/locations/{location_id}`
|
|
`projects/{projects_id}/sources/{source_id}/locations/{location_id}`
|
|
"""
|
|
resource_pattern = re.compile(
|
|
"(organizations|projects|folders)/.*/sources/[0-9]+"
|
|
)
|
|
if not resource_pattern.match(resource_name):
|
|
raise errors.InvalidSCCInputError(
|
|
"When providing a full resource path, it must also include "
|
|
"the organization, project, or folder prefix."
|
|
)
|
|
list_source_components = resource_name.split("/")
|
|
if version == "v1":
|
|
return f"{GetParentFromResourceName(resource_name)}/{list_source_components[2]}/{list_source_components[3]}"
|
|
if version == "v2":
|
|
# Include location.
|
|
return f"{GetParentFromResourceName(resource_name)}/{list_source_components[2]}/{list_source_components[3]}/{list_source_components[4]}/{list_source_components[5]}"
|
|
|
|
|
|
def GetFindingIdFromName(finding_name):
|
|
"""Gets a finding id from the full resource name."""
|
|
resource_pattern = re.compile(
|
|
"(organizations|projects|folders)/.*/sources/[0-9-]+/findings/[a-zA-Z0-9]+$"
|
|
)
|
|
region_resource_pattern = re.compile(
|
|
"(organizations|projects|folders)/.*/sources/[0-9-]+/locations/.*/findings/[a-zA-Z0-9]+$"
|
|
)
|
|
if not resource_pattern.match(
|
|
finding_name
|
|
) and not region_resource_pattern.match(finding_name):
|
|
raise errors.InvalidSCCInputError(
|
|
"When providing a full resource path, it must include the pattern "
|
|
"organizations/[0-9]+/sources/[0-9-]+/findings/[a-zA-Z0-9]+."
|
|
)
|
|
list_finding_components = finding_name.split("/")
|
|
return list_finding_components[len(list_finding_components) - 1]
|
|
|
|
|
|
def GetParentFromResourceName(resource_name):
|
|
list_organization_components = resource_name.split("/")
|
|
return f"{list_organization_components[0]}/{list_organization_components[1]}"
|
|
|
|
|
|
def ConvertStateInput(state, version):
|
|
"""Convert state input to messages.Finding.StateValueValuesEnum object."""
|
|
messages = securitycenter_client.GetMessages(version)
|
|
if state:
|
|
state = state.upper()
|
|
|
|
state_dict = {}
|
|
if version == "v1":
|
|
unspecified_state = messages.Finding.StateValueValuesEnum.STATE_UNSPECIFIED
|
|
state_dict["v1"] = {
|
|
"ACTIVE": messages.Finding.StateValueValuesEnum.ACTIVE,
|
|
"INACTIVE": messages.Finding.StateValueValuesEnum.INACTIVE,
|
|
"STATE_UNSPECIFIED": unspecified_state,
|
|
}
|
|
else:
|
|
v2_unspecified_state = (
|
|
messages.GoogleCloudSecuritycenterV2Finding.StateValueValuesEnum.STATE_UNSPECIFIED
|
|
)
|
|
state_dict["v2"] = {
|
|
"ACTIVE": (
|
|
messages.GoogleCloudSecuritycenterV2Finding.StateValueValuesEnum.ACTIVE
|
|
),
|
|
"INACTIVE": (
|
|
messages.GoogleCloudSecuritycenterV2Finding.StateValueValuesEnum.INACTIVE
|
|
),
|
|
"STATE_UNSPECIFIED": v2_unspecified_state,
|
|
}
|
|
return state_dict[version].get(
|
|
state, state_dict[version]["STATE_UNSPECIFIED"]
|
|
)
|
|
|
|
|
|
def ConvertMuteStateInput(mute_state, messages):
|
|
"""Convert mute state input to messages.BulkMuteFindingsRequest.MuteStateValueValuesEnum object."""
|
|
if mute_state == "muted":
|
|
return messages.BulkMuteFindingsRequest.MuteStateValueValuesEnum.MUTED
|
|
elif mute_state == "undefined":
|
|
return messages.BulkMuteFindingsRequest.MuteStateValueValuesEnum.UNDEFINED
|
|
raise errors.InvalidSCCInputError(
|
|
"Mute state must be one of [muted, undefined]."
|
|
)
|
|
|
|
|
|
def ValidateAndGetParent(args):
|
|
"""Validates parent."""
|
|
if args.organization is not None: # Validates organization.
|
|
if "/" in args.organization:
|
|
pattern = re.compile("^organizations/[0-9]{1,19}$")
|
|
if not pattern.match(args.organization):
|
|
raise errors.InvalidSCCInputError(
|
|
"When providing a full resource path, it must include the pattern "
|
|
"'^organizations/[0-9]{1,19}$'."
|
|
)
|
|
else:
|
|
return args.organization
|
|
else:
|
|
pattern = re.compile("^[0-9]{1,19}$")
|
|
if not pattern.match(args.organization):
|
|
raise errors.InvalidSCCInputError(
|
|
"Organization does not match the pattern '^[0-9]{1,19}$'."
|
|
)
|
|
else:
|
|
return f"organizations/{args.organization}"
|
|
|
|
if args.folder is not None: # Validates folder.
|
|
if "/" in args.folder:
|
|
pattern = re.compile("^folders/.*$")
|
|
if not pattern.match(args.folder):
|
|
raise errors.InvalidSCCInputError(
|
|
"When providing a full resource path, it must include the pattern "
|
|
"'^folders/.*$'."
|
|
)
|
|
else:
|
|
return args.folder
|
|
else:
|
|
return f"folders/{args.folder}"
|
|
|
|
if args.project is not None: # Validates project.
|
|
if "/" in args.project:
|
|
pattern = re.compile("^projects/.*$")
|
|
if not pattern.match(args.project):
|
|
raise errors.InvalidSCCInputError(
|
|
"When providing a full resource path, it must include the pattern "
|
|
"'^projects/.*$'."
|
|
)
|
|
else:
|
|
return args.project
|
|
else:
|
|
return f"projects/{args.project}"
|
|
|
|
|
|
def ValidateMutexOnSourceAndParent(args):
|
|
"""Validates that only a full resource name or split arguments are provided."""
|
|
if "/" in args.source and args.parent is not None:
|
|
raise errors.InvalidSCCInputError(
|
|
"Only provide a full resource name "
|
|
"(organizations/123/sources/456) or a --parent flag, not both."
|
|
)
|
|
|
|
|
|
def ExtractSecurityMarksFromResponse(response, args):
|
|
"""Returns security marks from finding response."""
|
|
del args
|
|
if isinstance(response, list):
|
|
list_finding_response = response
|
|
else:
|
|
list_finding_response = list(response)
|
|
if len(list_finding_response) > 1:
|
|
raise errors.InvalidSCCInputError(
|
|
"ListFindingResponse must only return one finding since it is "
|
|
"filtered by Finding Name."
|
|
)
|
|
for finding_result in list_finding_response:
|
|
return finding_result.finding.securityMarks
|
|
|
|
|
|
def ValidateSourceAndFindingIdIfParentProvided(args):
|
|
"""Validates that source and finding id are provided if parent is provided."""
|
|
if args.source is None:
|
|
raise errors.InvalidSCCInputError("--source flag must be provided.")
|
|
if "/" in args.finding:
|
|
raise errors.InvalidSCCInputError(
|
|
"Finding id must be provided, instead of the full resource name."
|
|
)
|
|
|
|
|
|
def ValidateLocationAndGetRegionalizedParent(args, parent):
|
|
"""Appends location to parent."""
|
|
if args.location:
|
|
if "/" in args.location:
|
|
pattern = re.compile("^locations/[A-Za-z0-9-]{0,61}$")
|
|
if not pattern.match(args.location):
|
|
raise errors.InvalidSCCInputError(
|
|
"When providing a full resource path, it must include the pattern "
|
|
"'^locations/.*$'."
|
|
)
|
|
else:
|
|
return f"{parent}/{args.location}"
|
|
else:
|
|
return f"{parent}/locations/{args.location}"
|
|
|
|
|
|
def GetRegionalizedResourceName(args, version):
|
|
"""Returns regionalized resource name."""
|
|
location = scc_util.ValidateAndGetLocation(args, version)
|
|
name_components = args.finding.split("/")
|
|
return f"{name_components[0]}/{name_components[1]}/{name_components[2]}/{name_components[3]}/locations/{location}/{name_components[4]}/{name_components[5]}"
|
|
|
|
|
|
def ConvertSourceProperties(source_properties_dict, version):
|
|
"""Hook to capture "key1=val1,key2=val2" as SourceProperties object."""
|
|
messages = securitycenter_client.GetMessages(version)
|
|
if version == "v1":
|
|
return encoding.DictToMessage(
|
|
source_properties_dict, messages.Finding.SourcePropertiesValue
|
|
)
|
|
elif version == "v2":
|
|
return encoding.DictToMessage(
|
|
source_properties_dict,
|
|
messages.GoogleCloudSecuritycenterV2Finding.SourcePropertiesValue,
|
|
)
|
|
raise errors.InvalidAPIVersion("Invalid API version")
|
|
|
|
|
|
def GetApiVersionUsingDeprecatedArgs(args, deprecated_args):
|
|
"""Determine what version to call from --location and --api-version."""
|
|
if not args.parent:
|
|
# If the parent argument is not provided in the command, it can be derived
|
|
# from properties set by gcloud config.
|
|
parent = scc_util.GetParentFromPositionalArguments(args)
|
|
else:
|
|
parent = args.parent
|
|
return scc_util.GetVersionFromArguments(args, parent, deprecated_args)
|
|
|
|
|
|
def ValidateAndFormatExportTime(export_time):
|
|
"""Validates the export time."""
|
|
try:
|
|
read_time_dt = times.ParseDateTime(export_time)
|
|
return times.FormatDateTime(read_time_dt)
|
|
except (times.DateTimeSyntaxError, times.DateTimeValueError) as e:
|
|
raise errors.InvalidSCCInputError(
|
|
"Invalid export time format. Please provide a valid date/time. Example:"
|
|
" 2024-08-20T12:00:00Z"
|
|
) from e
|
|
|
|
|
|
def ValidateDataset(dataset):
|
|
"""Validates the dataset."""
|
|
dataset_pattern = re.compile(
|
|
"^projects/[a-zA-Z0-9-]+/datasets/[a-zA-Z0-9_]+$"
|
|
)
|
|
if not dataset_pattern.match(dataset):
|
|
raise errors.InvalidSCCInputError(
|
|
"Dataset must match the pattern"
|
|
" projects/[a-zA-Z0-9-]+/datasets/[a-zA-Z0-9_]+."
|
|
)
|
|
return dataset
|