256 lines
10 KiB
Python
256 lines
10 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2025 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.
|
|
"""Command for semi-automatic remediation of SCC findings."""
|
|
|
|
import copy
|
|
import uuid
|
|
|
|
from googlecloudsdk.api_lib.scc.remediation_intents import const
|
|
from googlecloudsdk.api_lib.scc.remediation_intents import converters
|
|
from googlecloudsdk.api_lib.scc.remediation_intents import extended_service
|
|
from googlecloudsdk.api_lib.scc.remediation_intents import git
|
|
from googlecloudsdk.api_lib.scc.remediation_intents import sps_api
|
|
from googlecloudsdk.api_lib.scc.remediation_intents import terraform
|
|
from googlecloudsdk.api_lib.scc.remediation_intents import validators
|
|
from googlecloudsdk.calliope import base
|
|
from googlecloudsdk.command_lib.scc.remediation_intents import flags
|
|
from googlecloudsdk.core import log
|
|
|
|
|
|
@base.ReleaseTracks(base.ReleaseTrack.ALPHA)
|
|
@base.UniverseCompatible
|
|
|
|
|
|
class AutoRemediate(base.SilentCommand, base.CacheCommand):
|
|
"""Command for semi-automatic remediation of SCC findings."""
|
|
detailed_help = {
|
|
"DESCRIPTION": """
|
|
Orchestrates the semi-automatic remediation process for SCC findings
|
|
by calling the Remediation Intent APIs.
|
|
""",
|
|
|
|
"EXAMPLES": """
|
|
Sample usage:
|
|
Remediate a SCC finding for the organization 1234567890, in the
|
|
terraform repository located at ./terraform-repo.
|
|
$ {{command}} scc remediation-intents auto-remediate \\
|
|
--org-id=1234567890 \\
|
|
--root-dir-path=./terraform-repo \\
|
|
--git-config-path=./git-config.yaml""",
|
|
}
|
|
|
|
@staticmethod
|
|
def Args(parser):
|
|
flags.ROOT_DIR_PATH_FLAG.AddToParser(parser)
|
|
flags.ROOT_DIR_PATH_FLAG.SetDefault(parser, ".")
|
|
flags.ORG_ID_FLAG.AddToParser(parser)
|
|
flags.GIT_CONFIG_FILE_PATH_FLAG.AddToParser(parser)
|
|
|
|
def Run(self, args) -> None:
|
|
"""The main function which is called when the user runs this command.
|
|
|
|
Args:
|
|
args: an argparse namespace. All the arguments that were provided to this
|
|
command invocation.
|
|
"""
|
|
# Set up the variables.
|
|
org_id = args.org_id
|
|
git_config_data = args.git_config_path
|
|
root_dir_path = args.root_dir_path
|
|
# The extended service client to interact with the SPS service.
|
|
client = extended_service.ExtendedSPSClient(org_id, base.ReleaseTrack.ALPHA)
|
|
# The converter instance to handle conversion between different data types.
|
|
converter = converters.RemediationIntentConverter(base.ReleaseTrack.ALPHA)
|
|
messages = sps_api.GetMessagesModule(base.ReleaseTrack.ALPHA)
|
|
|
|
# Validate the input arguments.
|
|
validators.validate_git_config(git_config_data)
|
|
validators.validate_relative_dir_path(root_dir_path)
|
|
# Create a SCC finding Remediation Intent.
|
|
|
|
# Fetch an enqueued remediation intent which needs to be remediated.
|
|
intent_data = client.fetch_enqueued_remediation_intent()
|
|
if ( # Create a new intent if no enqueued intent is found.
|
|
intent_data is None
|
|
):
|
|
client.create_semi_autonomous_remediation_intent()
|
|
intent_data = client.fetch_enqueued_remediation_intent()
|
|
if intent_data is None:
|
|
# Exit gracefully if still no intent is found.
|
|
log.Print("No remediation intent found to be remediated, exitting...")
|
|
return
|
|
intent_name = intent_data.name
|
|
|
|
tf_files = terraform.fetch_tf_files(root_dir_path)
|
|
if not tf_files: # Exit gracefully if no TF files are found.
|
|
log.Print("No TF files found, exitting...")
|
|
return
|
|
# Parse the TFState file for the given finding data.
|
|
tfstate_data = terraform.parse_tf_file(
|
|
root_dir_path, intent_data.findingData
|
|
)
|
|
|
|
log.Print("Remediation started....")
|
|
# Update the state to REMEDIATION_IN_PROGRESS and start the remediation.
|
|
intent_updated = copy.deepcopy(intent_data)
|
|
intent_updated.state = ( # Mark the state as REMEDIATION_IN_PROGRESS.
|
|
messages.RemediationIntent.StateValueValuesEnum.REMEDIATION_IN_PROGRESS
|
|
)
|
|
intent_updated.remediationInput = messages.RemediationInput(
|
|
tfData=messages.TfData(
|
|
fileData=converter.DictFilesToMessage(tf_files),
|
|
tfStateInfo=tfstate_data,
|
|
)
|
|
)
|
|
update_mask = "state,remediation_input"
|
|
intent_updated = client.update_remediation_intent( # Call the Update API.
|
|
intent_name, update_mask, intent_updated
|
|
)
|
|
if (
|
|
intent_updated.state
|
|
== messages.RemediationIntent.StateValueValuesEnum.REMEDIATION_FAILED
|
|
):
|
|
log.Print("Remediation failed, exitting...")
|
|
return
|
|
|
|
# Retry the remediation process for certain number of times.
|
|
is_remediated = False
|
|
retry_count = 0
|
|
while not is_remediated and retry_count < const.REMEDIATION_RETRY_COUNT:
|
|
log.Print("Remediation retry count: ", retry_count)
|
|
updated_tf_files = converter.MessageFilesToDict(
|
|
intent_updated.remediatedOutput.outputData[0].tfData.fileData
|
|
)
|
|
error_msg = terraform.validate_tf_files(updated_tf_files)
|
|
if error_msg is None: # Remediation is successful.
|
|
is_remediated = True
|
|
break
|
|
# Send the error details to the server and retry the remediation.
|
|
intent_updated.remediationInput.errorDetails = messages.ErrorDetails(
|
|
reason=error_msg
|
|
)
|
|
update_mask = "remediation_input.error_details"
|
|
intent_updated = client.update_remediation_intent(
|
|
intent_name, update_mask, intent_updated
|
|
)
|
|
if (
|
|
intent_updated.state
|
|
== messages.RemediationIntent.StateValueValuesEnum.REMEDIATION_FAILED
|
|
):
|
|
log.Print("Remediation failed, exitting...")
|
|
return
|
|
retry_count += 1 # Upate the retry count.
|
|
log.Print("Remediation failed, retrying...")
|
|
|
|
if not is_remediated: # Mark the state as REMEDIATION_FAILED and exit.
|
|
log.Print("Remediation failed: Max retry limit reached.")
|
|
intent_updated.state = (
|
|
messages.RemediationIntent.StateValueValuesEnum.REMEDIATION_FAILED
|
|
)
|
|
update_mask = "state"
|
|
_ = client.update_remediation_intent( # Call the Update API.
|
|
intent_name, update_mask, intent_updated
|
|
)
|
|
return
|
|
|
|
log.Print("Remediation completed successfully.")
|
|
intent_updated.state = ( # Mark the state as REMEDIATION_SUCCESS.
|
|
messages.RemediationIntent.StateValueValuesEnum.REMEDIATION_SUCCESS
|
|
)
|
|
intent_updated.remediationInput.errorDetails = None
|
|
update_mask = "state,remediation_input.error_details"
|
|
intent_updated = client.update_remediation_intent( # Call the Update API.
|
|
intent_name, update_mask, intent_updated
|
|
)
|
|
|
|
# Generate the PR for the remediated output.
|
|
log.Print("Starting PR generation process...")
|
|
updated_tf_files = converter.MessageFilesToDict(
|
|
intent_updated.remediatedOutput.outputData[0].tfData.fileData
|
|
)
|
|
git_config_data["branch-prefix"] += str(uuid.uuid4())
|
|
|
|
git.push_commit(
|
|
updated_tf_files,
|
|
const.COMMIT_MSG.format(
|
|
project_id=intent_updated.findingData.findingName.split("/")[1],
|
|
finding_id=intent_updated.findingData.findingName.split("/")[-1],
|
|
category=intent_updated.findingData.category,
|
|
),
|
|
git_config_data["remote"],
|
|
git_config_data["branch-prefix"],
|
|
)
|
|
log.Print("Commit pushed successfully.")
|
|
# Add the remediation explanation to the PR description.
|
|
pr_status, pr_msg = git.create_pr(
|
|
const.PR_TITLE.format(
|
|
project_id=intent_updated.findingData.findingName.split("/")[1],
|
|
finding_id=intent_updated.findingData.findingName.split("/")[-1],
|
|
category=intent_updated.findingData.category,
|
|
),
|
|
const.PR_DESC.format(
|
|
remediation_explanation=intent_updated.remediatedOutput.remediationExplanation.replace(
|
|
"`", r"\`"
|
|
),
|
|
file_modifiers="\n".join(
|
|
f"{fp}: {ea}"
|
|
for fp, ea in (git.get_file_modifiers(updated_tf_files)).items()
|
|
),
|
|
file_owners="\n".join(
|
|
f"{fp}: {ea}"
|
|
for fp, ea in (
|
|
git.get_file_modifiers(updated_tf_files, first=True)
|
|
).items()
|
|
),
|
|
),
|
|
git_config_data["remote"],
|
|
git_config_data["branch-prefix"],
|
|
git_config_data["main-branch-name"],
|
|
git_config_data["reviewers"],
|
|
)
|
|
# Update the state and error details if the PR creation fails, and exit.
|
|
if not pr_status:
|
|
log.Print("PR creation failed, exitting...")
|
|
intent_updated.state = (
|
|
messages.RemediationIntent.StateValueValuesEnum.PR_GENERATION_FAILED
|
|
)
|
|
intent_updated.errorDetails = messages.ErrorDetails(reason=pr_msg)
|
|
update_mask = "state,error_details"
|
|
_ = client.update_remediation_intent(
|
|
intent_name, update_mask, intent_updated
|
|
)
|
|
return
|
|
|
|
# Finally Update the state and PR details if the PR creation is successful.
|
|
log.Print("PR created successfully.")
|
|
intent_updated.state = (
|
|
messages.RemediationIntent.StateValueValuesEnum.PR_GENERATION_SUCCESS
|
|
)
|
|
intent_updated.remediationArtifacts = messages.RemediationArtifacts(
|
|
prData=messages.PullRequest(
|
|
url=pr_msg,
|
|
modifiedFileOwners=list(
|
|
(git.get_file_modifiers(updated_tf_files, first=True)).values()
|
|
),
|
|
modifiedFilePaths=list(
|
|
(git.get_file_modifiers(updated_tf_files, first=True)).keys()
|
|
),
|
|
)
|
|
)
|
|
update_mask = "state,remediation_artifacts"
|
|
_ = client.update_remediation_intent(
|
|
intent_name, update_mask, intent_updated
|
|
)
|