Skip to content

Commit c531095

Browse files
authored
[GSOC 2026] Adding logic to validate and generate an error if there are key… (#38992)
* Feat: Adding logic to validate and generate an error if there are keys linked to a service account that was not rotated by our system * Fixing a critical bug to correctly identify obtained secrets * Feat: Add the notification system via Github/Issue for keys not managed by Beam's rotation system * Remove the unused Torch library and add a history for old reports to the problem body.
1 parent beeb715 commit c531095

2 files changed

Lines changed: 190 additions & 18 deletions

File tree

infra/enforcement/account_keys.py

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,53 @@ def _denormalize_username(self, username: str) -> str:
106106
return username.split(":", 1)[1].strip().lower()
107107
return username
108108

109+
def _get_user_managed_keys_from_iam(self, account_email: str) -> List[str]:
110+
""""
111+
Retrieves the list of user-managed keys for a given service account from IAM.
112+
113+
Args:
114+
account_email (str): The email of the service account to retrieve keys for.
115+
116+
Returns:
117+
List[str]: A list of key IDs for the user-managed keys associated with the service account
118+
"""
119+
request = types.ListServiceAccountKeysRequest()
120+
request.name = f"projects/{self.project_id}/serviceAccounts/{account_email}"
121+
request.key_types = [types.ListServiceAccountKeysRequest.KeyType.USER_MANAGED]
122+
123+
try:
124+
response = self.service_account_client.list_service_account_keys(request=request)
125+
return [key.name.split("/")[-1] for key in response.keys]
126+
except Exception as e:
127+
self.logger.error(f"Failed to retrieve keys for service account '{account_email}': {e}")
128+
return []
129+
130+
def _get_verified_keys_from_secret_manager(self, secret_name: str) -> List[str]:
131+
"""
132+
Retrieves the list of verified keys for a given service account from Secret Manager.
133+
134+
Args:
135+
secret_name (str): The name of the secret to retrieve keys for.
136+
137+
Returns:
138+
List[str]: A list of key IDs for the verified keys associated with the service account.
139+
"""
140+
verified_keys = []
141+
parent = self.secret_client.secret_path(self.project_id, secret_name)
142+
143+
try:
144+
versions = self.secret_client.list_secret_versions(request={"parent": parent})
145+
for version in versions:
146+
if version.state.name == secretmanager.SecretVersion.State.ENABLED:
147+
response = self.secret_client.access_secret_version(request={"name": version.name})
148+
data_str = response.payload.data.decode("UTF-8")
149+
key_id = data_str.split(":",1)[0]
150+
verified_keys.append(key_id)
151+
return verified_keys
152+
except Exception as e:
153+
self.logger.error(f"Failed to retrieve verified keys from Secret Manager for secret '{secret_name}': {e}")
154+
return []
155+
109156
def _get_all_live_service_accounts(self) -> List[str]:
110157
"""
111158
Retrieves all service accounts that are currently active (not disabled) in the project.
@@ -259,21 +306,35 @@ def check_compliance(self) -> List[str]:
259306
self.logger.info(f"No service account keys found in the {self.service_account_keys_file}.")
260307

261308
compliance_issues = []
309+
live_service_accounts = self._get_all_live_service_accounts()
310+
managed_secrets = self._get_all_live_managed_secrets()
262311

263312
# Check that all service accounts that exist are declared
264-
for service_account in self._get_all_live_service_accounts():
313+
for service_account in live_service_accounts:
265314
if self._denormalize_account_email(service_account) not in [account["account_id"] for account in file_service_accounts]:
266315
msg = f"Service account '{service_account}' is not declared in the service account keys file."
267316
compliance_issues.append(msg)
268317
self.logger.warning(msg)
318+
else:
319+
iam_keys = self._get_user_managed_keys_from_iam(service_account)
320+
if iam_keys:
321+
secret_name = f"{self._denormalize_account_email(service_account)}-key"
322+
legal_keys = []
323+
if secret_name in managed_secrets:
324+
legal_keys = self._get_verified_keys_from_secret_manager(secret_name)
325+
unmanaged_keys = set(iam_keys) - set(legal_keys)
326+
for unmanaged_key in unmanaged_keys:
327+
msg = f"SECURITY ALERT: Unmanaged key '{unmanaged_key}' detected on account '{service_account}'. This key was created outside of Beam's service account management system. "
328+
compliance_issues.append(msg)
329+
self.logger.warning(msg)
269330

270-
managed_secrets = self._get_all_live_managed_secrets()
271331
extracted_secrets = [f"{self._denormalize_account_email(account['account_id'])}-key" for account in file_service_accounts]
272332

273333
# Check for managed secrets that are not declared
274334
for secret in managed_secrets:
275335
if secret not in extracted_secrets:
276-
msg = f"Managed secret '{secret}' is not declared in the service account keys file."
336+
masked_secret = f"{secret[:4]}***{secret[-4:]}" if len(secret) >= 8 else "***"
337+
msg = f"Managed secret '{masked_secret}' is not declared in the service account keys file."
277338
compliance_issues.append(msg)
278339
self.logger.warning(msg)
279340

@@ -307,23 +368,34 @@ def create_announcement(self, recipient: str) -> None:
307368
"""
308369
if not self.sending_client:
309370
raise ValueError("SendingClient is required for creating announcements")
310-
371+
311372
diff = self.check_compliance()
312373

313374
if not diff:
314375
self.logger.info("No compliance issues found, no announcement will be created.")
315-
return
376+
return
316377

317-
title = f"Account Keys Compliance Issue Detected"
318-
body = f"Account keys for project {self.project_id} are not compliant with the defined policies on {self.service_account_keys_file}\n\n"
319-
for issue in diff:
320-
body += f"- {issue}\n"
378+
unmanaged_keys_issues = [issue for issue in diff if "SECURITY ALERT" in issue]
379+
general_issues = [issue for issue in diff if "SECURITY ALERT" not in issue]
321380

322-
announcement = f"Dear team,\n\nThis is an automated notification about compliance issues detected in the Account Keys policy for project {self.project_id}.\n\n"
323-
announcement += f"We found {len(diff)} compliance issue(s) that need your attention.\n"
324-
announcement += f"\nPlease check the GitHub issue for detailed information and take appropriate action to resolve these compliance violations."
381+
if general_issues:
382+
self.logger.info(f"Found {len(general_issues)} general compliance issues. Triggering announcement...")
383+
title = f"Account Keys Compliance Issue Detected"
384+
body = f"Account keys for project {self.project_id} are not compliant with the defined policies on {self.service_account_keys_file}\n\n"
385+
for issue in general_issues:
386+
body += f"- {issue}\n"
325387

326-
self.sending_client.create_announcement(title, body, recipient, announcement)
388+
announcement = f"Dear team,\n\nThis is an automated notification about compliance issues detected in the Account Keys policy for project {self.project_id}.\n\n"
389+
announcement += f"We found {len(general_issues)} compliance issue(s) that need your attention.\n"
390+
announcement += f"\nPlease check the GitHub issue for detailed information and take appropriate action to resolve these compliance violations."
391+
392+
self.sending_client.create_announcement(title, body, recipient, announcement)
393+
if unmanaged_keys_issues:
394+
self.logger.info(f"Found {len(unmanaged_keys_issues)} unmanaged key security alerts. Dispatching to GitHub security issue...")
395+
self.sending_client.report_unmanaged_keys(self.project_id, unmanaged_keys_issues)
396+
else:
397+
self.logger.info("No unmanaged key security alerts found, Checking if there are open security issues to auto-close...")
398+
self.sending_client.resolve_unmanaged_keys()
327399

328400
def print_announcement(self, recipient: str) -> None:
329401
"""
@@ -382,7 +454,8 @@ def generate_compliance(self) -> None:
382454
# Check for managed secrets that are not declared, if not, add them
383455
for secret in managed_secrets:
384456
if secret not in extracted_secrets:
385-
self.logger.info(f"Managed secret '{secret}' is not declared in the service account keys file, adding it")
457+
masked_secret = f"{secret[:4]}***{secret[-4:]}" if len(secret) >= 8 else "***"
458+
self.logger.info(f"Managed secret '{masked_secret}' is not declared in the service account keys file, adding it")
386459
file_service_accounts.append({
387460
"account_id": secret.strip("-key"),
388461
"display_name": self._normalize_account_email(secret.strip("-key")),
@@ -514,7 +587,7 @@ def main():
514587
logger.error(f"Unknown action: {action}")
515588
return 1
516589
except Exception as e:
517-
logger.error(f"Error executing action '{action}': {e}")
590+
logger.exception(f"Error executing action '{action}': {e}")
518591
return 1
519592

520593
return 0

infra/enforcement/sending.py

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,21 @@ def _get_open_issues(self, title: str) -> List[GitHubIssue]:
102102
Args:
103103
title (str): The title of the GitHub issue.
104104
"""
105-
endpoint = f"search/issues/?q=is:issue+repo:{self.github_repo}+in:title+{title}+is:open"
105+
endpoint = f"search/issues?q=is:issue+repo:{self.github_repo}+in:title+{title}+is:open"
106106
response = self._make_github_request("GET", endpoint)
107107
issues = response.json().get('items', [])
108-
return [GitHubIssue(**issue) for issue in issues]
108+
parsed_issues = []
109+
for issue in issues:
110+
parsed_issues.append(GitHubIssue(
111+
number=issue.get("number"),
112+
title=issue.get("title"),
113+
body=issue.get("body"),
114+
state=issue.get("state"),
115+
html_url=issue.get("html_url"),
116+
created_at=issue.get("created_at"),
117+
updated_at=issue.get("updated_at")
118+
))
119+
return parsed_issues
109120

110121
def create_issue(self, title: str, body: str) -> GitHubIssue:
111122
"""
@@ -119,7 +130,16 @@ def create_issue(self, title: str, body: str) -> GitHubIssue:
119130
payload = {"title": title, "body": body}
120131
response = self._make_github_request("POST", endpoint, json=payload)
121132
self.logger.info(f"Successfully created GitHub issue: {title}")
122-
return GitHubIssue(**response.json())
133+
data = response.json()
134+
return GitHubIssue(
135+
number=data.get("number"),
136+
title=data.get("title"),
137+
body=data.get("body"),
138+
state=data.get("state"),
139+
html_url=data.get("html_url"),
140+
created_at=data.get("created_at"),
141+
updated_at=data.get("updated_at")
142+
)
123143

124144
def update_issue_body(self, issue_number: int, new_body: str) -> None:
125145
"""
@@ -134,6 +154,85 @@ def update_issue_body(self, issue_number: int, new_body: str) -> None:
134154
self._make_github_request("PATCH", endpoint, json=payload)
135155
self.logger.info(f"Successfully updated body on GitHub issue: #{issue_number}")
136156

157+
def create_issue_comment(self, issue_number: int, comment_body: str) -> None:
158+
"""
159+
Adds a new comment to an existing GitHub issue in the specified repository.
160+
161+
Args:
162+
issue_number (int): The number of the GitHub issue to comment on.
163+
comment_body (str): The content of the comment to add to the GitHub issue.
164+
"""
165+
endpoint = f"repos/{self.github_repo}/issues/{issue_number}/comments"
166+
payload = {"body": comment_body}
167+
self._make_github_request("POST", endpoint, json=payload)
168+
self.logger.info(f"Successfully added comment to GitHub issue: #{issue_number}")
169+
170+
def report_unmanaged_keys(self, project_id: str, compilance_issues: List[str]) -> None:
171+
"""
172+
Report compliance issues regarding unmanaged keys into a single GitHub issue.
173+
Creates a new issue if none exists. If it exists, updates the body with the newest
174+
report and moves the previous content into a collapsed history section.
175+
176+
Args:
177+
project_id (str): The ID of the project associated with the unmanaged keys.
178+
compilance_issues (List[str]): A list of compliance issues related to the unmanaged keys.
179+
"""
180+
if not compilance_issues:
181+
self.logger.info("No compliance issues to report to Github.")
182+
return
183+
184+
issue_title = "[SECURITY] Action Required: Unmanaged Service Account Keys Detected"
185+
#markdown body
186+
timestamp = __import__("datetime").datetime.now(__import__("datetime").timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
187+
new_report = f"### Unmanaged Keys Audit Report ({timestamp})\n"
188+
new_report += f"The following unauthorized or unmanaged keys were detected in `{project_id}`:\n\n"
189+
190+
for issue_text in compilance_issues:
191+
new_report += f"- {issue_text}\n"
192+
193+
new_report += "\n*Please investigate and revoke these keys if they are not part of the official rotation system.*"
194+
open_issues = self._get_open_issues(issue_title)
195+
196+
if open_issues:
197+
target_issue = open_issues[0]
198+
self.logger.info(f"Appending report and archiving history to existing security issue #{target_issue.number}")
199+
200+
old_body = target_issue.body or ""
201+
history_marker = "### History\n<details>\n<summary>Click to expand</summary>\n\n"
202+
203+
if history_marker in old_body:
204+
# If history already exists, append the new report to it
205+
headed = old_body.split(history_marker)
206+
last_report = headed[0].strip()
207+
old_history = headed[1].replace("</details>", "").strip()
208+
209+
combined_history = f"{last_report}\n\n---\n\n{old_history}"
210+
else:
211+
combined_history = old_body.strip()
212+
213+
final_body = f"{new_report}\n\n{history_marker}{combined_history}\n</details>"
214+
self.update_issue_body(target_issue.number, final_body)
215+
else:
216+
self.logger.info("Creating new security issue for unmanaged keys report.")
217+
new_issue = self.create_issue(issue_title, new_report)
218+
self.logger.info(f"Created new security issue : {new_issue.html_url}.")
219+
220+
def resolve_unmanaged_keys(self) -> None:
221+
"""
222+
Finds any open security issues regarding rogue keys and automatically closes them
223+
if the infrastructure is now healthy.
224+
"""
225+
issue_title = "[SECURITY] Action Required: Unmanaged Service Account Keys Detected"
226+
open_issues = self._get_open_issues(issue_title)
227+
if open_issues:
228+
target_issue = open_issues[0]
229+
self.logger.info(f"All rogue keys resolved! Auto-closing issue #{target_issue.number}.")
230+
self.create_issue_comment(target_issue.number, "All previously reported unmanaged keys have been resolved. Closing this issue.")
231+
endpoint = f"repos/{self.github_repo}/issues/{target_issue.number}"
232+
payload = {"state": "closed"}
233+
self._make_github_request("PATCH", endpoint, json=payload)
234+
235+
137236
def create_announcement(self, title: str, body: str, recipient: str, announcement: str) -> None:
138237
"""
139238
This method sends an email with an announcement. The email will point to a GitHub issue.

0 commit comments

Comments
 (0)