blob: 37de025a207f681602dcb17efa4472cb6f871ec2 [file]
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You 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.
import requests
import logging
import smtplib, ssl
from typing import List, Optional
from dataclasses import dataclass
@dataclass
class GitHubIssue:
"""
Represents a GitHub issue.
"""
number: int
title: str
body: str
state: str
html_url: str
created_at: str
updated_at: str
class SendingClient:
"""
Sends notifications about GitHub issues.
"""
def __init__(self, logger: logging.Logger, github_token: str, github_repo: str,
smtp_server: str, smtp_port: int, email: str, password: str):
required_keys = [github_token, github_repo, smtp_server, smtp_port, email, password]
if not all(required_keys):
raise ValueError("All parameters must be provided.")
self.github_repo = github_repo
self.headers = {
"Authorization": f"Bearer {github_token}",
"X-GitHub-Api-Version": "2022-11-28",
"Accept": "application/vnd.github+json"
}
self.smtp_server = smtp_server
self.smtp_port = smtp_port
self.email = email
self.password = password
self.logger = logger
self.github_api_url = "https://api.github.com"
def _make_github_request(self, method: str, endpoint: str, json: Optional[dict] = None) -> requests.Response:
"""
Makes a request to the GitHub API.
Args:
method (str): The HTTP method to use (e.g., "GET", "POST", "PATCH").
endpoint (str): The API endpoint to call.
json (Optional[dict]): The JSON payload to send with the request.
Returns:
requests.Response: The response from the API.
"""
url = f"{self.github_api_url}/{endpoint}"
response = requests.request(method, url, headers=self.headers, json=json)
if not response.ok:
self.logger.error(f"Failed GitHub API request to {endpoint}: {response.status_code} - {response.text}")
response.raise_for_status()
return response
def _send_email(self, title: str, body: str, recipient: str) -> None:
"""
Sends an email notification.
Args:
title (str): The title of the email.
body (str): The body content of the email.
recipient (str): The email address of the recipient.
"""
message = f"Subject: {title}\n\n{body}"
context = ssl.create_default_context()
with smtplib.SMTP_SSL(self.smtp_server, self.smtp_port, context=context) as server:
server.login(self.email, self.password)
server.sendmail(self.email, recipient, message)
def _get_open_issues(self, title: str) -> List[GitHubIssue]:
"""
Retrieves the number of open GitHub issues with a given title.
Args:
title (str): The title of the GitHub issue.
"""
endpoint = f"search/issues?q=is:issue+repo:{self.github_repo}+in:title+{title}+is:open"
response = self._make_github_request("GET", endpoint)
issues = response.json().get('items', [])
parsed_issues = []
for issue in issues:
parsed_issues.append(GitHubIssue(
number=issue.get("number"),
title=issue.get("title"),
body=issue.get("body"),
state=issue.get("state"),
html_url=issue.get("html_url"),
created_at=issue.get("created_at"),
updated_at=issue.get("updated_at")
))
return parsed_issues
def create_issue(self, title: str, body: str) -> GitHubIssue:
"""
Creates a GitHub issue in the specified repository.
Args:
title (str): The title of the GitHub issue.
body (str): The body content of the GitHub issue.
"""
endpoint = f"repos/{self.github_repo}/issues"
payload = {"title": title, "body": body}
response = self._make_github_request("POST", endpoint, json=payload)
self.logger.info(f"Successfully created GitHub issue: {title}")
data = response.json()
return GitHubIssue(
number=data.get("number"),
title=data.get("title"),
body=data.get("body"),
state=data.get("state"),
html_url=data.get("html_url"),
created_at=data.get("created_at"),
updated_at=data.get("updated_at")
)
def update_issue_body(self, issue_number: int, new_body: str) -> None:
"""
Updates the body of a GitHub issue in the specified repository.
Args:
issue_number (int): The number of the GitHub issue to update.
new_body (str): The new body content for the GitHub issue.
"""
endpoint = f"repos/{self.github_repo}/issues/{issue_number}"
payload = {"body": new_body}
self._make_github_request("PATCH", endpoint, json=payload)
self.logger.info(f"Successfully updated body on GitHub issue: #{issue_number}")
def create_issue_comment(self, issue_number: int, comment_body: str) -> None:
"""
Adds a new comment to an existing GitHub issue in the specified repository.
Args:
issue_number (int): The number of the GitHub issue to comment on.
comment_body (str): The content of the comment to add to the GitHub issue.
"""
endpoint = f"repos/{self.github_repo}/issues/{issue_number}/comments"
payload = {"body": comment_body}
self._make_github_request("POST", endpoint, json=payload)
self.logger.info(f"Successfully added comment to GitHub issue: #{issue_number}")
def report_unmanaged_keys(self, project_id: str, compilance_issues: List[str]) -> None:
"""
Report compliance issues regarding unmanaged keys into a single GitHub issue.
Creates a new issue if none exists. If it exists, updates the body with the newest
report and moves the previous content into a collapsed history section.
Args:
project_id (str): The ID of the project associated with the unmanaged keys.
compilance_issues (List[str]): A list of compliance issues related to the unmanaged keys.
"""
if not compilance_issues:
self.logger.info("No compliance issues to report to Github.")
return
issue_title = "[SECURITY] Action Required: Unmanaged Service Account Keys Detected"
#markdown body
timestamp = __import__("datetime").datetime.now(__import__("datetime").timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
new_report = f"### Unmanaged Keys Audit Report ({timestamp})\n"
new_report += f"The following unauthorized or unmanaged keys were detected in `{project_id}`:\n\n"
for issue_text in compilance_issues:
new_report += f"- {issue_text}\n"
new_report += "\n*Please investigate and revoke these keys if they are not part of the official rotation system.*"
open_issues = self._get_open_issues(issue_title)
if open_issues:
target_issue = open_issues[0]
self.logger.info(f"Appending report and archiving history to existing security issue #{target_issue.number}")
old_body = target_issue.body or ""
history_marker = "### History\n<details>\n<summary>Click to expand</summary>\n\n"
if history_marker in old_body:
# If history already exists, append the new report to it
headed = old_body.split(history_marker)
last_report = headed[0].strip()
old_history = headed[1].replace("</details>", "").strip()
combined_history = f"{last_report}\n\n---\n\n{old_history}"
else:
combined_history = old_body.strip()
final_body = f"{new_report}\n\n{history_marker}{combined_history}\n</details>"
self.update_issue_body(target_issue.number, final_body)
else:
self.logger.info("Creating new security issue for unmanaged keys report.")
new_issue = self.create_issue(issue_title, new_report)
self.logger.info(f"Created new security issue : {new_issue.html_url}.")
def resolve_unmanaged_keys(self) -> None:
"""
Finds any open security issues regarding rogue keys and automatically closes them
if the infrastructure is now healthy.
"""
issue_title = "[SECURITY] Action Required: Unmanaged Service Account Keys Detected"
open_issues = self._get_open_issues(issue_title)
if open_issues:
target_issue = open_issues[0]
self.logger.info(f"All rogue keys resolved! Auto-closing issue #{target_issue.number}.")
self.create_issue_comment(target_issue.number, "All previously reported unmanaged keys have been resolved. Closing this issue.")
endpoint = f"repos/{self.github_repo}/issues/{target_issue.number}"
payload = {"state": "closed"}
self._make_github_request("PATCH", endpoint, json=payload)
def create_announcement(self, title: str, body: str, recipient: str, announcement: str) -> None:
"""
This method sends an email with an announcement. The email will point to a GitHub issue.
Creates a GitHub issue in the specified repository if it doesn't already exist.
If multiple open versions exist, the most recent one will be updated.
Args:
title (str): The title of the GitHub issue.
body (str): The body content of the GitHub issue.
recipient (str): The email address of the recipient.
announcement (str): The announcement message to include in the email.
"""
open_issues = self._get_open_issues(title)
open_issues.sort(key=lambda x: x.updated_at, reverse=True)
if open_issues:
self.logger.info(f"Issue with title '{title}' already exists: #{open_issues[0].number}")
announcement += f"\n\nRelated GitHub Issue: {open_issues[0].html_url}"
if open_issues[0].body != body:
self.logger.info(f"Updating body of issue #{open_issues[0].number}")
self.update_issue_body(open_issues[0].number, body)
else:
self.logger.info(f"No changes detected for issue #{open_issues[0].number}")
self._send_email(title, announcement, recipient)
else:
new_issue = self.create_issue(title, body)
announcement += f"\n\nRelated GitHub Issue: {new_issue.html_url}"
self._send_email(title, announcement, recipient)
def print_announcement(self, title: str, body: str, recipient: str, announcement: str) -> None:
"""
This method prints the data instead of sending the email or creating an issue.
This is used for testing.
"""
self.logger.info("Printing announcement...")
print(f"Simulating email sending...")
print(f"Recipient: {recipient}")
print(f"Announcement: {announcement}")
print("\nSimulating GitHub issue creation...")
print(f"Title: {title}")
print(f"Body: {body}")