Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/github_repo_auditor/app/security_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
3 changes: 2 additions & 1 deletion src/github_repo_auditor/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions src/github_repo_auditor/cli_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""
from __future__ import annotations

import re
import sys

try:
Expand All @@ -24,6 +25,47 @@
_stderr_console = Console(stderr=True) if HAS_RICH else None
_stdout_console = Console() if HAS_RICH else None

_SENSITIVE_ASSIGNMENT = re.compile(
r"(?i)(?P<prefix>(?P<key_quote>['\"]?)(?:access_token|api_key|apikey|"
r"client_secret|credential|github_token|password|private_key|secret|token)"
r"(?P=key_quote)\s*[:=]\s*)"
r"(?:(?P<quoted_value>\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')|"
r"(?P<bare_value>[^\r\n,;}\]]+))"
)
_AUTHORIZATION_VALUE = re.compile(
r"(?i)(?P<prefix>(?P<key_quote>['\"]?)authorization(?P=key_quote)\s*[:=]\s*)"
r"(?:(?P<quoted_value>\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')|"
r"(?P<bare_value>[^\r\n}\]]+))"
)
_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("<redacted>", str(msg))
redacted = _AUTHORIZATION_VALUE.sub(_redact_assignment, redacted)
redacted = _SENSITIVE_ASSIGNMENT.sub(_redact_assignment, redacted)
for pattern in _SENSITIVE_TOKENS:
redacted = pattern.sub("<redacted>", 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}<redacted>{value_quote}"


def create_progress() -> "Progress | None":
"""Create a Rich progress bar for stderr. Returns None if rich unavailable."""
Expand All @@ -42,6 +84,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:
Expand All @@ -50,6 +93,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:
Expand All @@ -58,6 +102,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:
Expand All @@ -66,6 +113,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:
Expand All @@ -74,6 +122,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:
Expand Down
6 changes: 4 additions & 2 deletions src/github_repo_auditor/operator_control_center_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
)
Expand Down
1 change: 1 addition & 0 deletions src/github_repo_auditor/portfolio_decision_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="")
Expand Down
45 changes: 45 additions & 0 deletions tests/test_cli_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
print_status,
print_success,
print_warning,
redact_sensitive_text,
)


Expand All @@ -20,6 +21,50 @@ 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=<redacted>"
assert redact_sensitive_text(f"request failed for {github_token}") == (
"request failed for <redacted>"
)

def test_redacts_complete_authorization_field_for_all_schemes(self):
assert redact_sensitive_text("Authorization: token opaque-value") == (
"Authorization: <redacted>"
)
assert redact_sensitive_text("Authorization: Basic dXNlcjpwYXNz") == (
"Authorization: <redacted>"
)
assert redact_sensitive_text(
'Authorization: Digest username="user", response="opaque"'
) == "Authorization: <redacted>"
assert redact_sensitive_text(
'{"Authorization": "Bearer opaque-value", "status": "failed"}'
) == '{"Authorization": "<redacted>", "status": "failed"}'

def test_redacts_quoted_credential_keys_and_values(self):
assert redact_sensitive_text('{"access_token": "opaque-secret"}') == (
'{"access_token": "<redacted>"}'
)
assert redact_sensitive_text("{'client_secret': 'opaque-secret'}") == (
"{'client_secret': '<redacted>'}"
)
assert redact_sensitive_text('password = "secret with spaces"') == (
'password = "<redacted>"'
)
assert redact_sensitive_text("password = secret with spaces; status=failed") == (
"password = <redacted>; status=failed"
)
assert redact_sensitive_text(r'{"token": "opaque\"tail"}') == (
r'{"token": "<redacted>"}'
)

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
Expand Down