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
50 changes: 44 additions & 6 deletions src/github_repo_auditor/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,43 +15,75 @@
"access_token",
"api_key",
"apikey",
"auth_token",
"authorization",
"client_secret",
"credential",
"password",
"private_key",
"refresh_token",
"github_token",
"secret",
"token",
"x_api_key",
}
)
_SENSITIVE_ASSIGNMENT = re.compile(
r"(?i)(?<![a-z0-9_-])(?:access[-_]?token|auth[-_]?token|refresh[-_]?token|"
r"x[-_]?api[-_]?key|api[-_]?key|apikey|authorization|client[-_]?secret|"
r"credential|github[-_]?token|password|private[-_]?key|secret|token)"
r"\s*[:=]\s*[^\s,;}&\]]+"
Comment on lines +32 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish credential assignments from ordinary prose

In the control-center artifact path, ordinary operator text such as OAuth refresh token: expired or Estimate token: 5 now satisfies this expression. Because write_control_center_artifacts applies the detector to the final rendered Markdown and raises before any weekly or control-center write, a non-secret queue title, summary, or recommendation containing that wording suppresses all artifacts; constrain this check to serialized credential fields or credential-shaped values and cover benign prose.

AGENTS.md reference: src/AGENTS.md:L17-L19

Useful? React with 👍 / 👎.

)
_URL_WITH_USERINFO = re.compile(r"https?://[^/@\s]+@", re.IGNORECASE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict URL userinfo detection to the authority

For a valid URL whose query starts immediately after the host, such as https://example.com?email=user@example.com, this regex scans through the query and mistakes the email's @ for userinfo. The shared detector consequently rejects benign cache/artifact content, while the parallel CLI regex rewrites the URL as https://<redacted>@example.com; parse or bound the authority instead of matching until any @.

AGENTS.md reference: AGENTS.md:L65-L70

Useful? React with 👍 / 👎.

_SENSITIVE_VALUE_PATTERNS = (
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"),
re.compile(r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----"),
re.compile(r"\bsecret_[A-Za-z0-9]{20,}\b"),
re.compile(r"\bntn_[A-Za-z0-9]{20,}\b"),
re.compile(r"-----BEGIN (?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY-----"),
)


def _normalized_field_name(value: object) -> str:
return re.sub(r"[^a-z0-9]+", "_", str(value).casefold()).strip("_")


def contains_sensitive_data(value: Any) -> bool:
"""Return whether JSON-compatible data contains credential data."""
if isinstance(value, dict):
return any(
str(key).lower() in _SENSITIVE_FIELD_NAMES or contains_sensitive_data(item)
_normalized_field_name(key) in _SENSITIVE_FIELD_NAMES
or contains_sensitive_data(item)
for key, item in value.items()
)
if isinstance(value, list):
return any(contains_sensitive_data(item) for item in value)
if isinstance(value, tuple):
return any(contains_sensitive_data(item) for item in value)
if isinstance(value, str):
return any(pattern.search(value) for pattern in _SENSITIVE_VALUE_PATTERNS)
return (
any(pattern.search(value) for pattern in _SENSITIVE_VALUE_PATTERNS)
or _SENSITIVE_ASSIGNMENT.search(value) is not None
or _URL_WITH_USERINFO.search(value) is not None
)
return False


def _url_has_sensitive_query(url: str) -> bool:
return any(name.lower() in _SENSITIVE_FIELD_NAMES for name, _value in parse_qsl(urlparse(url).query))
def _url_has_sensitive_components(url: str) -> bool:
parsed = urlparse(url)
names = (
name
for component in (parsed.query, parsed.fragment)
for name, _value in parse_qsl(component)
)
return any(_normalized_field_name(name) in _SENSITIVE_FIELD_NAMES for name in names)


def _url_has_embedded_credentials(url: str) -> bool:
parsed = urlparse(url)
return parsed.username is not None or parsed.password is not None


class ResponseCache:
Expand Down Expand Up @@ -95,7 +127,13 @@ def put(
response: object,
) -> None:
"""Store response data with current timestamp."""
if _url_has_sensitive_query(url) or contains_sensitive_data(params) or contains_sensitive_data(response):
if (
_url_has_sensitive_components(url)
or _url_has_embedded_credentials(url)
or contains_sensitive_data(url)
or contains_sensitive_data(params)
or contains_sensitive_data(response)
):
return
path = self._path(url, params)
entry = {
Expand Down
14 changes: 10 additions & 4 deletions src/github_repo_auditor/cli_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
_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"(?i)(?<![a-z0-9_-])(?P<prefix>(?P<key_quote>['\"]?)(?:access[-_]?token|"
r"auth[-_]?token|refresh[-_]?token|x[-_]?api[-_]?key|api[-_]?key|apikey|"
r"client[-_]?secret|credential|github[-_]?token|password|private[-_]?key|"
r"secret|token)"
r"(?P=key_quote)\s*[:=]\s*)"
r"(?:(?P<quoted_value>\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')|"
r"(?P<bare_value>[^\r\n,;}\]]+))"
Expand All @@ -38,21 +40,25 @@
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-----",
r"-----BEGIN (?P<key_kind>(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?)PRIVATE KEY-----.*?"
r"-----END (?P=key_kind)PRIVATE KEY-----",
re.DOTALL,
)
_URL_USERINFO = re.compile(r"(?P<scheme>https?://)[^/@\s]+@", re.IGNORECASE)
_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"),
re.compile(r"\bsecret_[A-Za-z0-9]{20,}\b"),
re.compile(r"\bntn_[A-Za-z0-9]{20,}\b"),
)


def redact_sensitive_text(msg: str) -> str:
"""Redact credential-shaped values before terminal output."""
redacted = _PRIVATE_KEY_BLOCK.sub("<redacted>", str(msg))
redacted = _URL_USERINFO.sub(r"\g<scheme><redacted>@", redacted)
redacted = _AUTHORIZATION_VALUE.sub(_redact_assignment, redacted)
redacted = _SENSITIVE_ASSIGNMENT.sub(_redact_assignment, redacted)
for pattern in _SENSITIVE_TOKENS:
Expand Down
136 changes: 126 additions & 10 deletions src/github_repo_auditor/operator_control_center_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
from __future__ import annotations

import json
import re
from datetime import datetime
from pathlib import Path

from github_repo_auditor.cache import contains_sensitive_data
from github_repo_auditor.cli_output import redact_sensitive_text
from github_repo_auditor.operator_artifact_paths import control_center_paths
from github_repo_auditor.operator_control_center import (
control_center_artifact_payload,
Expand All @@ -18,6 +20,16 @@
write_weekly_command_center_artifacts,
)

_SENSITIVE_LABELLED_VALUE = re.compile(
r"(?i)\b(?:[a-z0-9]+[-_])*(?:secret|token|password|credential)"
r"(?:[-_][a-z0-9]+)+\b"
)
_SAFE_MARKDOWN_TEXT = "<redacted>"
_SAFE_MARKDOWN_LANES = frozenset({"blocked", "urgent", "ready", "deferred"})
_SAFE_MARKDOWN_STATUSES = frozenset(
{"ok", "ready", "current", "warning", "blocked", "error", "unknown"}
)


def should_print_control_center_item(item: dict) -> bool:
catalog = item.get("portfolio_catalog") or {}
Expand Down Expand Up @@ -50,6 +62,88 @@ def filter_snapshot_for_default_view(snapshot: dict) -> dict:
return snapshot


def _looks_sensitive_key(key: str) -> bool:
lowered = str(key or "").strip().lower()
return any(
marker in lowered
for marker in (
"secret",
"token",
"password",
"passwd",
"credential",
"api_key",
"apikey",
"private_key",
"access_key",
)
)


def _redact_sensitive_values(value: object) -> object:
if isinstance(value, dict):
redacted: dict = {}
for k, v in value.items():
if _looks_sensitive_key(str(k)):
redacted[k] = "[REDACTED]"
else:
redacted[k] = _redact_sensitive_values(v)
return redacted
if isinstance(value, list):
return [_redact_sensitive_values(item) for item in value]
if isinstance(value, tuple):
return tuple(_redact_sensitive_values(item) for item in value)
if isinstance(value, str):
redacted_text = redact_sensitive_text(value)
return _SENSITIVE_LABELLED_VALUE.sub("<redacted>", redacted_text)
return value


def _sanitized_snapshot_for_rendering(snapshot: dict) -> dict:
"""Build a fail-closed projection for the persisted Markdown artifact.

Operator prose, identifiers, paths, and URLs may contain opaque secrets that
no pattern-based redactor can distinguish from ordinary text. Persist only
fixed explanatory text plus finite status/lane values and numeric counts.
"""
raw_setup = snapshot.get("operator_setup_health")
setup = raw_setup if isinstance(raw_setup, dict) else {}
status = str(setup.get("status", "unknown")).strip().lower()
safe_status = status if status in _SAFE_MARKDOWN_STATUSES else "unknown"

def safe_count(value: object) -> int:
return value if isinstance(value, int) and value >= 0 else 0

raw_queue = snapshot.get("operator_queue")
safe_queue = []
if isinstance(raw_queue, list):
for item in raw_queue:
if not isinstance(item, dict):
continue
lane = str(item.get("lane", "deferred")).strip().lower()
safe_queue.append(
{
"lane": lane if lane in _SAFE_MARKDOWN_LANES else "deferred",
"repo": "",
"title": _SAFE_MARKDOWN_TEXT,
"summary": _SAFE_MARKDOWN_TEXT,
"lane_reason": _SAFE_MARKDOWN_TEXT,
"recommended_action": _SAFE_MARKDOWN_TEXT,
}
)

return {
"operator_summary": {"headline": _SAFE_MARKDOWN_TEXT},
"operator_setup_health": {
"status": safe_status,
"blocking_errors": safe_count(setup.get("blocking_errors")),
"warnings": safe_count(setup.get("warnings")),
},
"operator_queue": safe_queue,
"operator_recent_changes": [],
}


def write_control_center_artifacts(
report_data: dict,
snapshot: dict,
Expand Down Expand Up @@ -77,26 +171,48 @@ def write_control_center_artifacts(
report_reference=report_reference,
generated_at=generated_at.isoformat(),
)
payload = control_center_artifact_payload(report_data, snapshot)
payload["weekly_command_center_digest_v1"] = weekly_digest
if contains_sensitive_data(weekly_digest):
raise ValueError("control-center artifacts must not persist credential fields")
markdown_snapshot = _sanitized_snapshot_for_rendering(snapshot)
sanitized_report_data = _redact_sensitive_values(report_data)
if not isinstance(sanitized_report_data, dict):
raise ValueError("control-center artifacts must not persist credential fields")
payload = control_center_artifact_payload(sanitized_report_data, markdown_snapshot)
sanitized_weekly_digest_for_payload = _redact_sensitive_values(weekly_digest)
payload["weekly_command_center_digest_v1"] = sanitized_weekly_digest_for_payload
sanitized_payload = _redact_sensitive_values(payload)
if not isinstance(sanitized_payload, dict):
raise ValueError("control-center artifacts must not persist credential fields")
payload = sanitized_payload
if contains_sensitive_data(payload) or contains_sensitive_data(snapshot):
raise ValueError("control-center artifacts must not persist credential fields")
if contains_sensitive_data(markdown_snapshot):
raise ValueError("control-center artifacts must not persist credential fields")
rendered_markdown = render_control_center_markdown(
markdown_snapshot, "operator", generated_at.date().isoformat()
)
if contains_sensitive_data(rendered_markdown):
raise ValueError("control-center artifacts must not persist credential fields")
safe_rendered_markdown = rendered_markdown
if contains_sensitive_data(safe_rendered_markdown):
raise ValueError("control-center artifacts must not persist credential fields")
sanitized_weekly_digest = _redact_sensitive_values(weekly_digest)
if not isinstance(sanitized_weekly_digest, dict):
raise ValueError("control-center artifacts must not persist credential fields")
if contains_sensitive_data(sanitized_weekly_digest):
raise ValueError("control-center artifacts must not persist credential fields")
weekly_json, weekly_md = write_weekly_command_center_artifacts(
output_dir,
username=username,
generated_at=generated_at,
digest=weekly_digest,
digest=sanitized_weekly_digest,
)
payload["weekly_command_center_reference"] = {
"json_path": str(weekly_json),
"markdown_path": str(weekly_md),
}
# Credential-shaped data is rejected above.
# codeql[py/clear-text-storage-sensitive-data]
# The payload is recursively sanitized and checked before persistence.
json_path.write_text(json.dumps(payload, indent=2))
# 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())
)
# The exact rendered value is redacted and rechecked immediately before persistence.
md_path.write_text(safe_rendered_markdown)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return json_path, md_path, weekly_json, weekly_md, payload
43 changes: 43 additions & 0 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import json
import time

import pytest

from github_repo_auditor.cache import ResponseCache


Expand Down Expand Up @@ -65,6 +67,47 @@ def test_put_skips_payloads_that_contain_credentials(self, tmp_path):

assert not cache._path(url, {"access_token": "secret-param", "per_page": "10"}).exists()

def test_put_skips_embedded_url_credentials(self, tmp_path):
cache = ResponseCache(cache_dir=tmp_path / "cache", ttl=3600)
url = "https://operator:opaque-value@example.invalid/repos"

cache.put(url, None, {"status": "ok"})

assert not cache._path(url, None).exists()

def test_put_skips_hyphenated_credential_alias(self, tmp_path):
cache = ResponseCache(cache_dir=tmp_path / "cache", ttl=3600)
url = "https://api.github.com/repos/user/repo"

cache.put(url, None, {"access-token": "opaque-value"})

assert not cache._path(url, None).exists()

@pytest.mark.parametrize("alias", ["auth-token", "refresh_token", "x-api-key"])
def test_put_skips_additional_credential_aliases(self, tmp_path, alias):
cache = ResponseCache(cache_dir=tmp_path / "cache", ttl=3600)
url = "https://api.github.com/repos/user/repo"

cache.put(url, None, {alias: "opaque-value"})

assert not cache._path(url, None).exists()

def test_put_skips_sensitive_url_fragment(self, tmp_path):
cache = ResponseCache(cache_dir=tmp_path / "cache", ttl=3600)
url = "https://example.invalid/callback#access-token=opaque-value"

cache.put(url, None, {"status": "ok"})

assert not cache._path(url, None).exists()

def test_put_skips_additional_provider_token_family(self, tmp_path):
cache = ResponseCache(cache_dir=tmp_path / "cache", ttl=3600)
url = "https://api.github.com/repos/user/repo"

cache.put(url, None, {"note": "secret_" + ("a" * 40)})

assert not cache._path(url, None).exists()

def test_put_skips_credential_shaped_value_under_benign_key(self, tmp_path):
cache = ResponseCache(cache_dir=tmp_path / "cache", ttl=3600)
url = "https://api.github.com/repos/user/repo"
Expand Down
Loading