github2gerrit 0.1.14__py3-none-any.whl → 0.1.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
github2gerrit/cli.py CHANGED
@@ -51,6 +51,7 @@ from .ssh_common import build_non_interactive_ssh_env
51
51
  from .utils import append_github_output
52
52
  from .utils import env_bool
53
53
  from .utils import env_str
54
+ from .utils import is_verbose_mode
54
55
  from .utils import log_exception_conditionally
55
56
  from .utils import parse_bool_env
56
57
 
@@ -989,9 +990,19 @@ def _process_single(
989
990
  else:
990
991
  progress_tracker.change_updated()
991
992
  except Exception as exc:
993
+ error_msg = str(exc)
992
994
  log.debug("Execution failed; continuing to write outputs: %s", exc)
995
+
996
+ # Always show the actual error to the user, not just in debug mode
993
997
  if progress_tracker:
994
- progress_tracker.add_error("Execution failed")
998
+ progress_tracker.add_error(f"Execution failed: {error_msg}")
999
+ else:
1000
+ # If no progress tracker, show error directly
1001
+ safe_console_print(f"❌ Error: {error_msg}", style="red")
1002
+
1003
+ # In verbose mode, also log the full exception with traceback
1004
+ if is_verbose_mode():
1005
+ log.exception("Full exception details:")
995
1006
 
996
1007
  result = SubmissionResult(
997
1008
  change_urls=[], change_numbers=[], commit_shas=[]
github2gerrit/core.py CHANGED
@@ -69,7 +69,7 @@ from .reconcile_matcher import LocalCommit
69
69
  from .reconcile_matcher import create_local_commit
70
70
  from .ssh_common import merge_known_hosts_content
71
71
  from .utils import env_bool
72
- from .utils import log_exception_conditionally
72
+ from .utils import is_verbose_mode
73
73
 
74
74
 
75
75
  try:
@@ -708,6 +708,8 @@ class Orchestrator:
708
708
  self._ssh_known_hosts_path: Path | None = None
709
709
  self._ssh_agent_manager: SSHAgentManager | None = None
710
710
  self._use_ssh_agent: bool = False
711
+ # Secure temporary directory for SSH files (outside workspace)
712
+ self._ssh_temp_dir: Path | None = None
711
713
  # Store inputs for access by helper methods
712
714
  self._inputs: Inputs | None = None
713
715
 
@@ -865,6 +867,9 @@ class Orchestrator:
865
867
 
866
868
  self._comment_on_pull_request(gh, gerrit, result)
867
869
 
870
+ # Validate that no unexpected files were committed
871
+ self._validate_committed_files(gh, result)
872
+
868
873
  self._close_pull_request_if_required(gh)
869
874
 
870
875
  log.debug("Pipeline complete: %s", result)
@@ -1111,10 +1116,15 @@ class Orchestrator:
1111
1116
 
1112
1117
  Does not modify user files.
1113
1118
  """
1119
+ log.debug(
1120
+ "Starting SSH setup for Gerrit %s:%d", gerrit.host, gerrit.port
1121
+ )
1114
1122
  if not inputs.gerrit_ssh_privkey_g2g:
1115
1123
  log.debug("SSH private key not provided, skipping SSH setup")
1116
1124
  return
1117
1125
 
1126
+ log.debug("SSH private key provided, proceeding with SSH configuration")
1127
+
1118
1128
  # Check for ssh-keyscan availability early if auto-discovery needed
1119
1129
  if (
1120
1130
  auto_discover_gerrit_host_keys is not None
@@ -1259,16 +1269,25 @@ class Orchestrator:
1259
1269
 
1260
1270
  # Check if SSH agent authentication is preferred
1261
1271
  use_ssh_agent = env_bool("G2G_USE_SSH_AGENT", default=True)
1272
+ log.debug(
1273
+ "SSH agent preference: use_ssh_agent=%s, "
1274
+ "setup_ssh_agent_auth available=%s",
1275
+ use_ssh_agent,
1276
+ setup_ssh_agent_auth is not None,
1277
+ )
1262
1278
 
1263
1279
  if use_ssh_agent and setup_ssh_agent_auth is not None:
1264
1280
  # Try SSH agent first as it's more secure and avoids file
1265
1281
  # permission issues
1282
+ log.debug("Attempting SSH agent-based authentication")
1266
1283
  if self._try_ssh_agent_setup(inputs, effective_known_hosts):
1284
+ log.debug("SSH agent setup successful")
1267
1285
  return
1268
1286
 
1269
1287
  # Fall back to file-based SSH if agent setup fails
1270
1288
  log.info("Falling back to file-based SSH authentication")
1271
1289
 
1290
+ log.debug("Using file-based SSH authentication")
1272
1291
  self._setup_file_based_ssh(inputs, effective_known_hosts)
1273
1292
 
1274
1293
  def _try_ssh_agent_setup(
@@ -1288,8 +1307,36 @@ class Orchestrator:
1288
1307
 
1289
1308
  try:
1290
1309
  log.debug("Setting up SSH agent-based authentication (more secure)")
1310
+ log.debug("Workspace: %s", self.workspace)
1311
+ log.debug(
1312
+ "Private key length: %d characters",
1313
+ len(inputs.gerrit_ssh_privkey_g2g),
1314
+ )
1315
+ log.debug(
1316
+ "Known hosts length: %d characters", len(effective_known_hosts)
1317
+ )
1318
+
1319
+ # Create secure separate temp directory for SSH agent if needed
1320
+ import secrets
1321
+ import tempfile
1322
+
1323
+ if not self._ssh_temp_dir:
1324
+ # Use secure random suffix to prevent predictable paths
1325
+ secure_suffix = secrets.token_hex(8)
1326
+ self._ssh_temp_dir = Path(
1327
+ tempfile.mkdtemp(
1328
+ prefix=f"g2g_ssh_{secure_suffix}_",
1329
+ dir=tempfile.gettempdir(),
1330
+ )
1331
+ )
1332
+ # Ensure directory has restrictive permissions
1333
+ self._ssh_temp_dir.chmod(0o700)
1334
+ log.debug(
1335
+ "Created secure SSH temp directory: %s", self._ssh_temp_dir
1336
+ )
1337
+
1291
1338
  self._ssh_agent_manager = setup_ssh_agent_auth(
1292
- workspace=self.workspace,
1339
+ workspace=self._ssh_temp_dir,
1293
1340
  private_key_content=inputs.gerrit_ssh_privkey_g2g,
1294
1341
  known_hosts_content=effective_known_hosts,
1295
1342
  )
@@ -1297,6 +1344,7 @@ class Orchestrator:
1297
1344
  log.debug("SSH agent authentication configured successfully")
1298
1345
 
1299
1346
  except Exception as exc:
1347
+ log.debug("SSH agent setup failed: %s", exc)
1300
1348
  log.warning(
1301
1349
  "SSH agent setup failed, falling back to file-based SSH: %s",
1302
1350
  exc,
@@ -1317,28 +1365,60 @@ class Orchestrator:
1317
1365
  inputs: Validated input configuration
1318
1366
  effective_known_hosts: Known hosts content
1319
1367
  """
1320
- log.info("Setting up file-based SSH configuration for Gerrit")
1321
- log.debug("Using workspace-specific SSH files to avoid user changes")
1368
+ log.debug("Using file-based SSH configuration for Gerrit")
1369
+ log.debug(
1370
+ "Using secure temporary SSH files outside workspace to prevent "
1371
+ "artifacts"
1372
+ )
1373
+ log.debug(
1374
+ "Private key length: %d characters",
1375
+ len(inputs.gerrit_ssh_privkey_g2g),
1376
+ )
1377
+ log.debug(
1378
+ "Known hosts length: %d characters", len(effective_known_hosts)
1379
+ )
1322
1380
 
1323
- # Create tool-specific SSH directory in workspace to avoid touching
1324
- # user files
1325
- tool_ssh_dir = self.workspace / ".ssh-g2g"
1381
+ # Create secure tool-specific SSH directory outside workspace to prevent
1382
+ # SSH artifacts from being accidentally committed
1383
+ import secrets
1384
+ import tempfile
1385
+
1386
+ if not self._ssh_temp_dir:
1387
+ # Use secure random suffix to prevent predictable paths
1388
+ secure_suffix = secrets.token_hex(8)
1389
+ self._ssh_temp_dir = Path(
1390
+ tempfile.mkdtemp(
1391
+ prefix=f"g2g_ssh_{secure_suffix}_",
1392
+ dir=tempfile.gettempdir(),
1393
+ )
1394
+ )
1395
+ # Ensure directory has restrictive permissions
1396
+ self._ssh_temp_dir.chmod(0o700)
1397
+ log.debug(
1398
+ "Created secure SSH temp directory: %s", self._ssh_temp_dir
1399
+ )
1400
+ tool_ssh_dir = self._ssh_temp_dir
1326
1401
  tool_ssh_dir.mkdir(mode=0o700, exist_ok=True)
1327
1402
 
1328
1403
  # Write SSH private key to tool-specific location with secure
1329
1404
  # permissions
1330
1405
  key_path = tool_ssh_dir / "gerrit_key"
1406
+ log.debug("SSH key file path: %s", key_path)
1331
1407
 
1332
1408
  # Use a more robust approach for creating the file with secure
1333
1409
  # permissions
1334
1410
  key_content = inputs.gerrit_ssh_privkey_g2g.strip() + "\n"
1335
1411
 
1336
1412
  # Multiple strategies to create secure key file
1413
+ log.debug("Attempting to create secure key file")
1337
1414
  success = self._create_secure_key_file(key_path, key_content)
1415
+ log.debug("Secure key file creation success: %s", success)
1338
1416
 
1339
1417
  if not success:
1340
1418
  # If all permission strategies fail, create in memory directory
1419
+ log.debug("Falling back to memory-based key file creation")
1341
1420
  success = self._create_key_in_memory_fs(key_path, key_content)
1421
+ log.debug("Memory-based key file creation success: %s", success)
1342
1422
 
1343
1423
  if not success:
1344
1424
  msg = (
@@ -1347,8 +1427,11 @@ class Orchestrator:
1347
1427
  "Consider using G2G_USE_SSH_AGENT=true (default) for SSH "
1348
1428
  "agent authentication."
1349
1429
  )
1430
+ log.error("SSH key file creation failed: %s", msg)
1350
1431
  raise RuntimeError(msg)
1351
1432
 
1433
+ log.debug("SSH key file created successfully: %s", key_path)
1434
+
1352
1435
  # Write known hosts to tool-specific location
1353
1436
  known_hosts_path = tool_ssh_dir / "known_hosts"
1354
1437
  with open(known_hosts_path, "w", encoding="utf-8") as f:
@@ -1360,6 +1443,7 @@ class Orchestrator:
1360
1443
  # Store paths for later use in git commands
1361
1444
  self._ssh_key_path = key_path
1362
1445
  self._ssh_known_hosts_path = known_hosts_path
1446
+ log.debug("File-based SSH setup completed successfully")
1363
1447
 
1364
1448
  def _create_secure_key_file(self, key_path: Path, key_content: str) -> bool:
1365
1449
  """Try multiple strategies to create a secure SSH key file.
@@ -1564,29 +1648,46 @@ class Orchestrator:
1564
1648
  """Centralized non-interactive SSH/Git environment."""
1565
1649
  from .ssh_common import build_non_interactive_ssh_env
1566
1650
 
1651
+ log.debug("Building SSH environment for git operations")
1567
1652
  env = build_non_interactive_ssh_env()
1568
1653
 
1569
1654
  # Set GIT_SSH_COMMAND based on available configuration
1570
1655
  cmd = self._build_git_ssh_command
1571
1656
  if cmd:
1657
+ log.debug("Using custom GIT_SSH_COMMAND: %s", cmd)
1572
1658
  env["GIT_SSH_COMMAND"] = cmd
1573
1659
  else:
1574
1660
  # Fallback to basic non-interactive SSH command
1575
1661
  from .ssh_common import build_git_ssh_command
1576
1662
 
1577
- env["GIT_SSH_COMMAND"] = build_git_ssh_command()
1663
+ fallback_cmd = build_git_ssh_command()
1664
+ log.debug("Using fallback GIT_SSH_COMMAND: %s", fallback_cmd)
1665
+ env["GIT_SSH_COMMAND"] = fallback_cmd
1578
1666
 
1579
1667
  # Override SSH agent settings if using SSH agent
1580
1668
  if self._use_ssh_agent and self._ssh_agent_manager:
1581
- env.update(self._ssh_agent_manager.get_ssh_env())
1669
+ log.debug("Applying SSH agent environment variables")
1670
+ agent_env = self._ssh_agent_manager.get_ssh_env()
1671
+ log.debug(
1672
+ "SSH agent environment: %s",
1673
+ {k: v for k, v in agent_env.items() if "SSH" in k},
1674
+ )
1675
+ env.update(agent_env)
1676
+ else:
1677
+ log.debug(
1678
+ "Not using SSH agent (use_ssh_agent=%s, manager=%s)",
1679
+ self._use_ssh_agent,
1680
+ self._ssh_agent_manager is not None,
1681
+ )
1582
1682
 
1683
+ log.debug("Final SSH environment contains %d variables", len(env))
1583
1684
  return env
1584
1685
 
1585
1686
  def _cleanup_ssh(self) -> None:
1586
1687
  """Clean up temporary SSH files created by this tool.
1587
1688
 
1588
- Removes the workspace-specific .ssh-g2g directory and all contents.
1589
- This ensures no temporary files are left behind.
1689
+ Securely removes the separate SSH temporary directory and all contents.
1690
+ This ensures no temporary files or credentials are left behind.
1590
1691
  """
1591
1692
  log.debug("Cleaning up temporary SSH configuration files")
1592
1693
 
@@ -1597,15 +1698,47 @@ class Orchestrator:
1597
1698
  self._ssh_agent_manager = None
1598
1699
  self._use_ssh_agent = False
1599
1700
 
1600
- # Remove temporary SSH directory and all contents
1601
- tool_ssh_dir = self.workspace / ".ssh-g2g"
1602
- if tool_ssh_dir.exists():
1701
+ # Securely remove separate SSH temporary directory and all contents
1702
+ if self._ssh_temp_dir and self._ssh_temp_dir.exists():
1703
+ import os
1603
1704
  import shutil
1604
1705
 
1605
- shutil.rmtree(tool_ssh_dir)
1706
+ # First, overwrite any key files to prevent recovery
1707
+ try:
1708
+ for root, _dirs, files in os.walk(self._ssh_temp_dir):
1709
+ for file in files:
1710
+ file_path = Path(root) / file
1711
+ if file_path.exists() and file_path.is_file():
1712
+ # Overwrite file with random data
1713
+ try:
1714
+ size = file_path.stat().st_size
1715
+ if size > 0:
1716
+ import secrets
1717
+
1718
+ with open(file_path, "wb") as f:
1719
+ f.write(secrets.token_bytes(size))
1720
+ # Sync to ensure write completes
1721
+ os.fsync(f.fileno())
1722
+ except Exception as overwrite_exc:
1723
+ log.debug(
1724
+ "Failed to overwrite %s: %s",
1725
+ file_path,
1726
+ overwrite_exc,
1727
+ )
1728
+ except Exception as walk_exc:
1729
+ log.debug(
1730
+ "Failed to walk SSH temp directory for secure "
1731
+ "cleanup: %s",
1732
+ walk_exc,
1733
+ )
1734
+
1735
+ # Remove the directory tree
1736
+ shutil.rmtree(self._ssh_temp_dir)
1606
1737
  log.debug(
1607
- "Cleaned up temporary SSH directory: %s", tool_ssh_dir
1738
+ "Securely cleaned up temporary SSH directory: %s",
1739
+ self._ssh_temp_dir,
1608
1740
  )
1741
+ self._ssh_temp_dir = None
1609
1742
  except Exception as exc:
1610
1743
  log.warning("Failed to clean up temporary SSH files: %s", exc)
1611
1744
 
@@ -1821,8 +1954,8 @@ class Orchestrator:
1821
1954
  cur_msg = _clean_ellipses_from_message(cur_msg)
1822
1955
  needed = [m for m in meta if m not in cur_msg]
1823
1956
  if needed:
1824
- new_msg = (
1825
- cur_msg.rstrip() + "\n" + "\n".join(needed) + "\n"
1957
+ new_msg = Orchestrator._append_missing_trailers(
1958
+ cur_msg, needed
1826
1959
  )
1827
1960
  git_commit_amend(
1828
1961
  message=new_msg,
@@ -2113,7 +2246,9 @@ class Orchestrator:
2113
2246
  if meta:
2114
2247
  needed = [m for m in meta if m not in commit_msg]
2115
2248
  if needed:
2116
- commit_msg = commit_msg.rstrip() + "\n" + "\n".join(needed)
2249
+ commit_msg = Orchestrator._append_missing_trailers(
2250
+ commit_msg, needed
2251
+ )
2117
2252
  except Exception as meta_exc:
2118
2253
  log.debug(
2119
2254
  "Skipping metadata trailer injection (squash path): %s",
@@ -2314,7 +2449,9 @@ class Orchestrator:
2314
2449
  ).stdout
2315
2450
  needed = [m for m in meta if m not in cur_msg]
2316
2451
  if needed:
2317
- new_msg = cur_msg.rstrip() + "\n" + "\n".join(needed) + "\n"
2452
+ new_msg = Orchestrator._append_missing_trailers(
2453
+ cur_msg, needed
2454
+ )
2318
2455
  git_commit_amend(
2319
2456
  cwd=self.workspace,
2320
2457
  no_edit=False,
@@ -2327,6 +2464,32 @@ class Orchestrator:
2327
2464
  "Skipping post-apply metadata trailer ensure: %s", meta_exc
2328
2465
  )
2329
2466
 
2467
+ @staticmethod
2468
+ def _append_missing_trailers(
2469
+ message: str, trailers: list[str], *, ensure_final_newline: bool = True
2470
+ ) -> str:
2471
+ """Append missing trailers to a commit message with proper formatting.
2472
+
2473
+ Args:
2474
+ message: The base commit message
2475
+ trailers: List of trailer lines to potentially append
2476
+ ensure_final_newline: Whether to ensure the message ends with a
2477
+ newline
2478
+
2479
+ Returns:
2480
+ The message with missing trailers appended, properly formatted
2481
+ """
2482
+ needed = [trailer for trailer in trailers if trailer not in message]
2483
+ if not needed:
2484
+ return message
2485
+
2486
+ result = message.rstrip() + "\n\n" + "\n".join(needed)
2487
+
2488
+ if ensure_final_newline:
2489
+ result = result.rstrip("\n") + "\n"
2490
+
2491
+ return result
2492
+
2330
2493
  def _push_to_gerrit(
2331
2494
  self,
2332
2495
  *,
@@ -2369,6 +2532,7 @@ class Orchestrator:
2369
2532
  "-t",
2370
2533
  topic,
2371
2534
  ]
2535
+ log.debug("Building git review command with topic: %s", topic)
2372
2536
  collected_change_ids: list[str] = []
2373
2537
  if prepared:
2374
2538
  collected_change_ids.extend(prepared.all_change_ids())
@@ -2396,7 +2560,15 @@ class Orchestrator:
2396
2560
  gerrit, repo, branch, reviewers, topic, env
2397
2561
  )
2398
2562
  return
2399
- log.debug("Executing git review command: %s", " ".join(args))
2563
+
2564
+ log.debug("Final git review command: %s", " ".join(args))
2565
+ log.debug(
2566
+ "Git review environment variables: %s",
2567
+ {k: v for k, v in env.items() if "SSH" in k or "GIT" in k},
2568
+ )
2569
+ log.debug("Working directory: %s", self.workspace)
2570
+
2571
+ # Execute the git review command
2400
2572
  run_cmd(args, cwd=self.workspace, env=env)
2401
2573
  log.debug("Successfully pushed changes to Gerrit")
2402
2574
  except CommandError as exc:
@@ -2498,9 +2670,33 @@ class Orchestrator:
2498
2670
 
2499
2671
  # Analyze the specific failure reason from git review output
2500
2672
  error_details = self._analyze_gerrit_push_failure(exc)
2501
- log_exception_conditionally(
2502
- log, "Gerrit push failed: %s", error_details
2503
- )
2673
+
2674
+ # Always log the error details, even if not in verbose mode
2675
+ log.exception("Gerrit push failed: %s", error_details)
2676
+
2677
+ # In debug mode, also show the raw command output
2678
+ if is_verbose_mode():
2679
+ log.debug("Git review command: %s", " ".join(exc.cmd or []))
2680
+ log.debug("Return code: %s", exc.returncode)
2681
+ if exc.stdout:
2682
+ log.debug("Command stdout:\n%s", exc.stdout)
2683
+ if exc.stderr:
2684
+ log.debug("Command stderr:\n%s", exc.stderr)
2685
+
2686
+ # Include raw output in error message if analysis didn't provide
2687
+ # useful info
2688
+ has_raw_output = exc.stdout or exc.stderr
2689
+ if error_details.startswith("Unknown error") and has_raw_output:
2690
+ raw_output = ""
2691
+ if exc.stdout:
2692
+ raw_output += f"stdout: {exc.stdout.strip()}\n"
2693
+ if exc.stderr:
2694
+ raw_output += f"stderr: {exc.stderr.strip()}"
2695
+ if raw_output:
2696
+ error_details = (
2697
+ f"{error_details}\nRaw output:\n{raw_output}"
2698
+ )
2699
+
2504
2700
  msg = (
2505
2701
  f"Failed to push changes to Gerrit with git-review: "
2506
2702
  f"{error_details}"
@@ -2758,9 +2954,10 @@ class Orchestrator:
2758
2954
  env=env,
2759
2955
  check=False,
2760
2956
  )
2761
- # 2) Clean untracked files/dirs (preserve our SSH known_hosts dir)
2957
+ # 2) Clean untracked files/dirs (SSH files are now outside
2958
+ # workspace)
2762
2959
  run_cmd(
2763
- ["git", "clean", "-fdx", "-e", ".ssh-g2g", "-e", ".ssh-g2g/**"],
2960
+ ["git", "clean", "-fdx"],
2764
2961
  cwd=self.workspace,
2765
2962
  env=env,
2766
2963
  check=False,
@@ -2968,7 +3165,15 @@ class Orchestrator:
2968
3165
  return f"Gerrit rejected the push: {line.strip()}"
2969
3166
  return "Gerrit rejected the push for an unknown reason"
2970
3167
  else:
2971
- return f"Unknown error: {exc}"
3168
+ # For unknown errors, include more context
3169
+ context_parts = []
3170
+ if exc.returncode is not None:
3171
+ context_parts.append(f"exit code {exc.returncode}")
3172
+ if exc.cmd:
3173
+ context_parts.append(f"command: {' '.join(exc.cmd)}")
3174
+
3175
+ context = f" ({', '.join(context_parts)})" if context_parts else ""
3176
+ return f"Unknown error{context}: {exc}"
2972
3177
 
2973
3178
  def _query_gerrit_for_results(
2974
3179
  self,
@@ -3933,6 +4138,128 @@ class Orchestrator:
3933
4138
  out.append(c)
3934
4139
  return out
3935
4140
 
4141
+ def _validate_committed_files(
4142
+ self, gh: GitHubContext, result: SubmissionResult
4143
+ ) -> None:
4144
+ """Validate that only expected files from the GitHub PR were
4145
+ committed to Gerrit.
4146
+
4147
+ This is a safety check to ensure no tool artifacts (like SSH keys) were
4148
+ accidentally included in the Gerrit change.
4149
+ """
4150
+ if not gh.pr_number or not result.commit_shas:
4151
+ log.debug("Skipping file validation - no PR number or commit SHAs")
4152
+ return
4153
+
4154
+ try:
4155
+ # Get files changed in the GitHub PR
4156
+ from .github_api import build_client
4157
+ from .github_api import get_pull
4158
+ from .github_api import get_repo_from_env
4159
+
4160
+ client = build_client()
4161
+ repo = get_repo_from_env(client)
4162
+ pr_obj = get_pull(repo, int(gh.pr_number))
4163
+
4164
+ # Get list of files changed in the PR
4165
+ github_files = set()
4166
+ for file in pr_obj.get_files(): # type: ignore[attr-defined]
4167
+ github_files.add(file.filename)
4168
+
4169
+ log.debug(
4170
+ "GitHub PR files (%d): %s",
4171
+ len(github_files),
4172
+ sorted(github_files),
4173
+ )
4174
+
4175
+ # Check files in each commit SHA that was pushed to Gerrit
4176
+ for commit_sha in result.commit_shas:
4177
+ try:
4178
+ # Get files changed in the Gerrit commit
4179
+ from .gitutils import run_cmd
4180
+
4181
+ files_output = run_cmd(
4182
+ [
4183
+ "git",
4184
+ "show",
4185
+ "--name-only",
4186
+ "--pretty=format:",
4187
+ commit_sha,
4188
+ ],
4189
+ cwd=self.workspace,
4190
+ ).stdout.strip()
4191
+
4192
+ if not files_output:
4193
+ continue
4194
+
4195
+ gerrit_files = {
4196
+ f.strip() for f in files_output.split("\n") if f.strip()
4197
+ }
4198
+ log.debug(
4199
+ "Gerrit commit %s files (%d): %s",
4200
+ commit_sha[:8],
4201
+ len(gerrit_files),
4202
+ sorted(gerrit_files),
4203
+ )
4204
+
4205
+ # Check for unexpected files
4206
+ unexpected_files = gerrit_files - github_files
4207
+ if unexpected_files:
4208
+ # Filter out known safe files that might legitimately
4209
+ # differ
4210
+ suspicious_files = []
4211
+ for f in unexpected_files:
4212
+ # Skip files that are legitimately different
4213
+ if f in [".gitreview", ".gitignore"]:
4214
+ continue
4215
+ # Flag SSH artifacts and other suspicious files
4216
+ if (
4217
+ ".ssh" in f
4218
+ or "known_hosts" in f
4219
+ or f.startswith("gerrit_key")
4220
+ ):
4221
+ suspicious_files.append(f)
4222
+ else:
4223
+ # Other unexpected files - log but don't error
4224
+ log.warning(
4225
+ "Unexpected file in Gerrit commit: %s", f
4226
+ )
4227
+
4228
+ if suspicious_files:
4229
+ log.error(
4230
+ "❌ CRITICAL: SSH artifacts detected in Gerrit "
4231
+ "commit %s: %s",
4232
+ commit_sha[:8],
4233
+ suspicious_files,
4234
+ )
4235
+ log.error(
4236
+ "This indicates a serious bug where tool "
4237
+ "artifacts were committed. The Gerrit change "
4238
+ "may need manual cleanup."
4239
+ )
4240
+ # Don't fail the pipeline, but log prominently for
4241
+ # monitoring
4242
+
4243
+ # Also check if we're missing expected files
4244
+ missing_files = github_files - gerrit_files
4245
+ if missing_files:
4246
+ log.warning(
4247
+ "Files in GitHub PR but not in Gerrit commit "
4248
+ "%s: %s",
4249
+ commit_sha[:8],
4250
+ sorted(missing_files),
4251
+ )
4252
+
4253
+ except Exception as commit_exc:
4254
+ log.debug(
4255
+ "Failed to validate files for commit %s: %s",
4256
+ commit_sha[:8],
4257
+ commit_exc,
4258
+ )
4259
+
4260
+ except Exception as exc:
4261
+ log.debug("File validation failed (non-critical): %s", exc)
4262
+
3936
4263
 
3937
4264
  # ---------------------
3938
4265
  # Utility functions
@@ -53,7 +53,8 @@ class SSHAgentManager:
53
53
  """Initialize SSH agent manager.
54
54
 
55
55
  Args:
56
- workspace: The workspace directory for storing temporary files
56
+ workspace: Secure temporary directory for storing SSH files (outside
57
+ git workspace)
57
58
  """
58
59
  self.workspace = workspace
59
60
  self.agent_pid: int | None = None
@@ -160,7 +161,9 @@ class SSHAgentManager:
160
161
  known_hosts_content: The known hosts content
161
162
  """
162
163
  try:
163
- # Create tool-specific SSH directory
164
+ # Create tool-specific SSH directory in secure temp location
165
+ # Note: workspace is now a separate secure temp directory outside
166
+ # git workspace
164
167
  tool_ssh_dir = self.workspace / ".ssh-g2g"
165
168
  tool_ssh_dir.mkdir(mode=0o700, exist_ok=True)
166
169
 
@@ -259,7 +262,7 @@ class SSHAgentManager:
259
262
  return result.stdout
260
263
 
261
264
  def cleanup(self) -> None:
262
- """Clean up SSH agent and temporary files."""
265
+ """Securely clean up SSH agent and temporary files."""
263
266
  try:
264
267
  # Kill SSH agent if we started it
265
268
  if self.agent_pid:
@@ -276,14 +279,44 @@ class SSHAgentManager:
276
279
  elif key in os.environ:
277
280
  del os.environ[key]
278
281
 
279
- # Clean up temporary files
282
+ # Securely clean up temporary files
280
283
  tool_ssh_dir = self.workspace / ".ssh-g2g"
281
284
  if tool_ssh_dir.exists():
282
285
  import shutil
283
286
 
287
+ # First, overwrite any key files to prevent recovery
288
+ try:
289
+ for root, _dirs, files in os.walk(tool_ssh_dir):
290
+ for file in files:
291
+ file_path = Path(root) / file
292
+ if file_path.exists() and file_path.is_file():
293
+ # Overwrite file with random data
294
+ try:
295
+ size = file_path.stat().st_size
296
+ if size > 0:
297
+ import secrets
298
+
299
+ with open(file_path, "wb") as f:
300
+ f.write(secrets.token_bytes(size))
301
+ # Sync to ensure write completes
302
+ os.fsync(f.fileno())
303
+ except Exception as overwrite_exc:
304
+ log.debug(
305
+ "Failed to overwrite %s: %s",
306
+ file_path,
307
+ overwrite_exc,
308
+ )
309
+ except Exception as walk_exc:
310
+ log.debug(
311
+ "Failed to walk SSH temp directory for secure "
312
+ "cleanup: %s",
313
+ walk_exc,
314
+ )
315
+
284
316
  shutil.rmtree(tool_ssh_dir)
285
317
  log.debug(
286
- "Cleaned up temporary SSH directory: %s", tool_ssh_dir
318
+ "Securely cleaned up temporary SSH directory: %s",
319
+ tool_ssh_dir,
287
320
  )
288
321
 
289
322
  except Exception as exc:
@@ -300,7 +333,8 @@ def setup_ssh_agent_auth(
300
333
  """Setup SSH agent-based authentication.
301
334
 
302
335
  Args:
303
- workspace: The workspace directory
336
+ workspace: Secure temporary directory for SSH files (outside git
337
+ workspace)
304
338
  private_key_content: SSH private key content
305
339
  known_hosts_content: Known hosts content
306
340
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github2gerrit
3
- Version: 0.1.14
3
+ Version: 0.1.15
4
4
  Summary: Submit a GitHub pull request to a Gerrit repository.
5
5
  Project-URL: Homepage, https://github.com/lfreleng-actions/github2gerrit
6
6
  Project-URL: Repository, https://github.com/lfreleng-actions/github2gerrit
@@ -1,8 +1,8 @@
1
1
  github2gerrit/__init__.py,sha256=N1Vj1HJ28LKCJLAynQdm5jFGQQAz9YSMzZhEfvbBgow,886
2
- github2gerrit/cli.py,sha256=iJ8s15Wslgt9FfHjUEELlrP5JGLDqn9j5GoH5aNZxVU,65286
2
+ github2gerrit/cli.py,sha256=t0jR0bN_EGtqzSguFyWdCzvCXvBBHQZQhqBddUnLHyk,65769
3
3
  github2gerrit/commit_normalization.py,sha256=5HqUJ7p3WQzUYNTkganIsu82aW1wZrh7J7ivQvdCYXw,17033
4
4
  github2gerrit/config.py,sha256=khuXAfmOc2wqO4r1Cpp8PYk04AO-JpmnJvSWQZ3fdL8,22315
5
- github2gerrit/core.py,sha256=b6cmEX7Aa2LpcdqXm8VIKM9cqd4wi50vKeYXEo1H_Ek,149356
5
+ github2gerrit/core.py,sha256=ScUS29VeyjGB6Ha3omnk495eLsHXBsz-ROmAbVqVQBo,162943
6
6
  github2gerrit/duplicate_detection.py,sha256=HSL1IpyfPk0yLkzCKA8mWadYB3qENR6Ar3AfiH59lCI,28766
7
7
  github2gerrit/external_api.py,sha256=9483kkgIs1ECOl_f0lcGb8GrJQF9IfYmWfBQwUJT9hk,18480
8
8
  github2gerrit/gerrit_query.py,sha256=7AR9UEwZxtTb9V-l17UDdtWvlvROlnIeJVMa57ilf6s,8343
@@ -17,15 +17,15 @@ github2gerrit/reconcile_matcher.py,sha256=UpFAGqOD0NSKJKqKN_ErpNtqG0MEXxVEMywnKW
17
17
  github2gerrit/rich_display.py,sha256=hA2lXdbG3FOPozI_JTyU52o67oCS9LMUyc2odEpaAjo,15466
18
18
  github2gerrit/rich_logging.py,sha256=AEcPksmg2kj3eFOZHuU-_kWzGCKh-m__ZgDzwECBtaY,10281
19
19
  github2gerrit/similarity.py,sha256=-YHnscTrVh1jdfA3JRLPkbVjaZ5FpFdmXe8UPlTG1ss,15888
20
- github2gerrit/ssh_agent_setup.py,sha256=Wni94Zs86AVYalmVKfZ8lRzBnWybtJUwDltdpMC4Qnw,12864
20
+ github2gerrit/ssh_agent_setup.py,sha256=dwEP7b2EAd0QXm4mCdgbdbGMqmYWk8z1U_EswBAySnI,14610
21
21
  github2gerrit/ssh_common.py,sha256=ClABtIqXiYIfUn58Shw7uHZ6Ham1ysHn0EpcUHjQ8D8,8261
22
22
  github2gerrit/ssh_discovery.py,sha256=g8acjEczGEtYuAJzIRsLDAHTQzyS9RaHOoQV5dAMoes,17754
23
23
  github2gerrit/trailers.py,sha256=9w0vIxPNBNQp56sIy-MF62d22Rm6vY-msh9ao1lX0rQ,8384
24
24
  github2gerrit/utils.py,sha256=1CKTsQo_FO3eyVjzNUT3XyFnyObIxyEFLeSmVKzavVo,3397
25
25
  github2gerrit/orchestrator/__init__.py,sha256=HAEcdCAHOFr8LsdIwAdcIcFZn_ayMbX9rdVUULp8410,864
26
26
  github2gerrit/orchestrator/reconciliation.py,sha256=qLhmjnIblSa_PVmBblWJ1gSvJ6Gto2AIVOVdYvT6zbk,18364
27
- github2gerrit-0.1.14.dist-info/METADATA,sha256=YPSKTfw0f9j33f7uybQvkbeRqnSRdA4lLAxk1fkBw7s,33833
28
- github2gerrit-0.1.14.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
- github2gerrit-0.1.14.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
30
- github2gerrit-0.1.14.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
31
- github2gerrit-0.1.14.dist-info/RECORD,,
27
+ github2gerrit-0.1.15.dist-info/METADATA,sha256=1q7uAI2-erRFQNi23SQq64aYQVUa6Khj426xUKqWhK0,33833
28
+ github2gerrit-0.1.15.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
+ github2gerrit-0.1.15.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
30
+ github2gerrit-0.1.15.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
31
+ github2gerrit-0.1.15.dist-info/RECORD,,