From f4af8d02707b840e6dd6fb8616415bb195b53b42 Mon Sep 17 00:00:00 2001 From: saagpatel <269905221+saagpatel@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:30:51 -0700 Subject: [PATCH 1/2] fix(security): redact CLI credential output --- src/github_repo_auditor/app/security_modes.py | 6 ++- src/github_repo_auditor/cache.py | 3 +- src/github_repo_auditor/cli_output.py | 37 +++++++++++++++++++ .../operator_control_center_artifacts.py | 6 ++- .../portfolio_decision_queue.py | 1 + tests/test_cli_output.py | 17 +++++++++ 6 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/github_repo_auditor/app/security_modes.py b/src/github_repo_auditor/app/security_modes.py index 7fe542e7..8c146e69 100644 --- a/src/github_repo_auditor/app/security_modes.py +++ b/src/github_repo_auditor/app/security_modes.py @@ -85,10 +85,12 @@ def run_security_gate_mode(args: Any) -> None: max_age_hours=getattr(args, "max_age_hours", None), ) if getattr(args, "json", False): - # lgtm[py/clear-text-logging-sensitive-data] Count-only alert summary; no secret values. + # Count-only alert summary; no secret values. + # codeql[py/clear-text-logging-sensitive-data] print(json.dumps(report.to_dict(), indent=2)) else: - # lgtm[py/clear-text-logging-sensitive-data] Count-only alert summary; no secret values. + # Count-only alert summary; no secret values. + # codeql[py/clear-text-logging-sensitive-data] print(render_security_gate_markdown(report)) if not report.passed: raise SystemExit(1) diff --git a/src/github_repo_auditor/cache.py b/src/github_repo_auditor/cache.py index 94d8ea71..077829de 100644 --- a/src/github_repo_auditor/cache.py +++ b/src/github_repo_auditor/cache.py @@ -105,7 +105,8 @@ def put( "cached_at": time.time(), } try: - # lgtm[py/clear-text-storage-sensitive-data] Credential-shaped data is rejected above. + # Credential-shaped data is rejected above. + # codeql[py/clear-text-storage-sensitive-data] path.write_text(json.dumps(entry)) except OSError: pass # Cache write failure is non-fatal diff --git a/src/github_repo_auditor/cli_output.py b/src/github_repo_auditor/cli_output.py index c7228593..c92377b1 100644 --- a/src/github_repo_auditor/cli_output.py +++ b/src/github_repo_auditor/cli_output.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import re import sys try: @@ -24,6 +25,35 @@ _stderr_console = Console(stderr=True) if HAS_RICH else None _stdout_console = Console() if HAS_RICH else None +_SENSITIVE_ASSIGNMENT = re.compile( + r"(?i)\b(access_token|api_key|apikey|authorization|client_secret|credential|" + r"github_token|password|private_key|secret|token)(\s*[:=]\s*)([^\s,;]+)" +) +_AUTHORIZATION_VALUE = re.compile( + r"(?i)\bauthorization(\s*[:=]\s*)(?:bearer\s+)?([^\s,;]+)" +) +_PRIVATE_KEY_BLOCK = re.compile( + r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----.*?" + r"-----END (?:RSA |EC |DSA )?PRIVATE KEY-----", + re.DOTALL, +) +_SENSITIVE_TOKENS = ( + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), + re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"), + re.compile(r"\bxox[bpors]-[A-Za-z0-9-]{10,}\b"), +) + + +def redact_sensitive_text(msg: str) -> str: + """Redact credential-shaped values before terminal output.""" + redacted = _PRIVATE_KEY_BLOCK.sub("", str(msg)) + redacted = _AUTHORIZATION_VALUE.sub(r"authorization\1", redacted) + redacted = _SENSITIVE_ASSIGNMENT.sub(r"\1\2", redacted) + for pattern in _SENSITIVE_TOKENS: + redacted = pattern.sub("", redacted) + return redacted + def create_progress() -> "Progress | None": """Create a Rich progress bar for stderr. Returns None if rich unavailable.""" @@ -42,6 +72,7 @@ def create_progress() -> "Progress | None": def print_status(msg: str) -> None: """Print a styled status message to stderr.""" + msg = redact_sensitive_text(msg) if HAS_RICH: _stderr_console.print(f" [bold]{msg}[/bold]") else: @@ -50,6 +81,7 @@ def print_status(msg: str) -> None: def print_warning(msg: str) -> None: """Print a yellow warning to stderr.""" + msg = redact_sensitive_text(msg) if HAS_RICH: _stderr_console.print(f" [yellow]⚠ {msg}[/yellow]") else: @@ -58,6 +90,9 @@ def print_warning(msg: str) -> None: def print_info(msg: str) -> None: """Print an info message to stderr.""" + msg = redact_sensitive_text(msg) + # The shared output boundary redacts credential assignments and known token forms above. + # codeql[py/clear-text-logging-sensitive-data] if HAS_RICH: _stderr_console.print(f" [dim]{msg}[/dim]") else: @@ -66,6 +101,7 @@ def print_info(msg: str) -> None: def print_success(msg: str) -> None: """Print a green success message to stdout.""" + msg = redact_sensitive_text(msg) if HAS_RICH: _stdout_console.print(f"[green]✓[/green] {msg}") else: @@ -74,6 +110,7 @@ def print_success(msg: str) -> None: def print_summary(lines: list[str]) -> None: """Print multi-line summary to stdout.""" + lines = [redact_sensitive_text(line) for line in lines] if HAS_RICH: _stdout_console.print("\n".join(lines)) else: diff --git a/src/github_repo_auditor/operator_control_center_artifacts.py b/src/github_repo_auditor/operator_control_center_artifacts.py index bb2dcbc0..28ee4f5d 100644 --- a/src/github_repo_auditor/operator_control_center_artifacts.py +++ b/src/github_repo_auditor/operator_control_center_artifacts.py @@ -91,9 +91,11 @@ def write_control_center_artifacts( "json_path": str(weekly_json), "markdown_path": str(weekly_md), } - # lgtm[py/clear-text-storage-sensitive-data] Credential-shaped data is rejected above. + # Credential-shaped data is rejected above. + # codeql[py/clear-text-storage-sensitive-data] json_path.write_text(json.dumps(payload, indent=2)) - # lgtm[py/clear-text-storage-sensitive-data] Credential-shaped data is rejected above. + # Credential-shaped data is rejected above. + # codeql[py/clear-text-storage-sensitive-data] md_path.write_text( render_control_center_markdown(snapshot, username, generated_at.isoformat()) ) diff --git a/src/github_repo_auditor/portfolio_decision_queue.py b/src/github_repo_auditor/portfolio_decision_queue.py index 2ba9f852..a2a9bc9a 100644 --- a/src/github_repo_auditor/portfolio_decision_queue.py +++ b/src/github_repo_auditor/portfolio_decision_queue.py @@ -750,6 +750,7 @@ def main(argv: list[str] | None = None) -> int: if args.format == "json": # The digest contains aggregate finding counts and receipt metadata, # never secret values; the regression test exercises that boundary. + # codeql[py/clear-text-logging-sensitive-data] print(json.dumps(digest, indent=2, sort_keys=True)) else: print(render_decision_digest_markdown(digest), end="") diff --git a/tests/test_cli_output.py b/tests/test_cli_output.py index 14f7dcf0..b3045d00 100644 --- a/tests/test_cli_output.py +++ b/tests/test_cli_output.py @@ -5,6 +5,7 @@ print_status, print_success, print_warning, + redact_sensitive_text, ) @@ -20,6 +21,22 @@ def test_create_progress_returns_progress(self): class TestHelpers: + def test_redacts_named_and_provider_credentials(self): + github_token = "ghp_" + "a" * 36 + + assert redact_sensitive_text("token=plain-secret") == "token=" + assert redact_sensitive_text("Authorization: Bearer opaque-value") == ( + "authorization: " + ) + assert redact_sensitive_text(f"request failed for {github_token}") == ( + "request failed for " + ) + + def test_preserves_noncredential_status_text(self): + assert redact_sensitive_text("scanned=5 high=11 critical=0") == ( + "scanned=5 high=11 critical=0" + ) + def test_print_status_no_crash(self, capsys): print_status("Testing status") # Should not raise From f6d39d788b549f1721cb8b77726e1f9f60aca55b Mon Sep 17 00:00:00 2001 From: saagpatel <269905221+saagpatel@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:22:25 -0700 Subject: [PATCH 2/2] fix(security): close credential redaction gaps --- src/github_repo_auditor/cli_output.py | 22 +++++++++++++---- tests/test_cli_output.py | 34 ++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/github_repo_auditor/cli_output.py b/src/github_repo_auditor/cli_output.py index c92377b1..1df6f29e 100644 --- a/src/github_repo_auditor/cli_output.py +++ b/src/github_repo_auditor/cli_output.py @@ -26,11 +26,16 @@ _stdout_console = Console() if HAS_RICH else None _SENSITIVE_ASSIGNMENT = re.compile( - r"(?i)\b(access_token|api_key|apikey|authorization|client_secret|credential|" - r"github_token|password|private_key|secret|token)(\s*[:=]\s*)([^\s,;]+)" + r"(?i)(?P(?P['\"]?)(?:access_token|api_key|apikey|" + r"client_secret|credential|github_token|password|private_key|secret|token)" + r"(?P=key_quote)\s*[:=]\s*)" + r"(?:(?P\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')|" + r"(?P[^\r\n,;}\]]+))" ) _AUTHORIZATION_VALUE = re.compile( - r"(?i)\bauthorization(\s*[:=]\s*)(?:bearer\s+)?([^\s,;]+)" + r"(?i)(?P(?P['\"]?)authorization(?P=key_quote)\s*[:=]\s*)" + r"(?:(?P\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')|" + r"(?P[^\r\n}\]]+))" ) _PRIVATE_KEY_BLOCK = re.compile( r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----.*?" @@ -48,13 +53,20 @@ def redact_sensitive_text(msg: str) -> str: """Redact credential-shaped values before terminal output.""" redacted = _PRIVATE_KEY_BLOCK.sub("", str(msg)) - redacted = _AUTHORIZATION_VALUE.sub(r"authorization\1", redacted) - redacted = _SENSITIVE_ASSIGNMENT.sub(r"\1\2", redacted) + redacted = _AUTHORIZATION_VALUE.sub(_redact_assignment, redacted) + redacted = _SENSITIVE_ASSIGNMENT.sub(_redact_assignment, redacted) for pattern in _SENSITIVE_TOKENS: redacted = pattern.sub("", redacted) return redacted +def _redact_assignment(match: re.Match[str]) -> str: + """Preserve assignment syntax while replacing its complete value.""" + quoted_value = match.group("quoted_value") or "" + value_quote = quoted_value[:1] + return f"{match.group('prefix')}{value_quote}{value_quote}" + + def create_progress() -> "Progress | None": """Create a Rich progress bar for stderr. Returns None if rich unavailable.""" if not HAS_RICH: diff --git a/tests/test_cli_output.py b/tests/test_cli_output.py index b3045d00..a84c1cfe 100644 --- a/tests/test_cli_output.py +++ b/tests/test_cli_output.py @@ -25,13 +25,41 @@ def test_redacts_named_and_provider_credentials(self): github_token = "ghp_" + "a" * 36 assert redact_sensitive_text("token=plain-secret") == "token=" - assert redact_sensitive_text("Authorization: Bearer opaque-value") == ( - "authorization: " - ) assert redact_sensitive_text(f"request failed for {github_token}") == ( "request failed for " ) + def test_redacts_complete_authorization_field_for_all_schemes(self): + assert redact_sensitive_text("Authorization: token opaque-value") == ( + "Authorization: " + ) + assert redact_sensitive_text("Authorization: Basic dXNlcjpwYXNz") == ( + "Authorization: " + ) + assert redact_sensitive_text( + 'Authorization: Digest username="user", response="opaque"' + ) == "Authorization: " + assert redact_sensitive_text( + '{"Authorization": "Bearer opaque-value", "status": "failed"}' + ) == '{"Authorization": "", "status": "failed"}' + + def test_redacts_quoted_credential_keys_and_values(self): + assert redact_sensitive_text('{"access_token": "opaque-secret"}') == ( + '{"access_token": ""}' + ) + assert redact_sensitive_text("{'client_secret': 'opaque-secret'}") == ( + "{'client_secret': ''}" + ) + assert redact_sensitive_text('password = "secret with spaces"') == ( + 'password = ""' + ) + assert redact_sensitive_text("password = secret with spaces; status=failed") == ( + "password = ; status=failed" + ) + assert redact_sensitive_text(r'{"token": "opaque\"tail"}') == ( + r'{"token": ""}' + ) + def test_preserves_noncredential_status_text(self): assert redact_sensitive_text("scanned=5 high=11 critical=0") == ( "scanned=5 high=11 critical=0"