diff --git a/.github/workflows/update-qpk-pin.yml b/.github/workflows/update-qpk-pin.yml index 9d169d4..406f203 100644 --- a/.github/workflows/update-qpk-pin.yml +++ b/.github/workflows/update-qpk-pin.yml @@ -66,10 +66,20 @@ jobs: python -c "from quant_platform_kit.common.contracts import SnapshotProfileContract; print('contracts OK')" # Verify strategy repos installable with NEW constraints + failed=0 for dep in us-equity-strategies hk-equity-strategies cn-equity-strategies crypto-strategies; do echo "Checking $dep..." - python -m pip install --dry-run -c constraints.txt "$dep" >/dev/null 2>&1 && echo " $dep OK" || echo " $dep FAILED" + if python -m pip install --dry-run -c constraints.txt "$dep" >/dev/null 2>&1; then + echo " $dep OK" + else + echo " $dep FAILED" + failed=1 + fi done + if [ "${failed}" -ne 0 ]; then + echo "One or more downstream dependency checks failed." >&2 + exit 1 + fi echo "::endgroup::" echo "All compatibility checks passed." @@ -77,10 +87,7 @@ jobs: if: steps.update.outputs.changed == 'true' uses: peter-evans/create-pull-request@v7 with: - # Use PAT to bypass GH013 ruleset (GITHUB_TOKEN is blocked from creating PRs). - # Create a classic PAT at https://github.com/settings/tokens with `public_repo` - # scope, then add it as org secret `QSL_AUTOMATION_PAT`. - token: ${{ secrets.QSL_AUTOMATION_PAT || secrets.GITHUB_TOKEN }} + token: ${{ github.token }} commit-message: "chore: auto-update QPK_PIN and constraints.txt" title: "chore: auto-update QPK_PIN and constraints.txt" body: | diff --git a/pyproject.toml b/pyproject.toml index eaaf2f5..adcec6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=68"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] @@ -8,7 +8,7 @@ version = "0.10.0" description = "QuantStrategyLab shared runtime: broker adapters, domain models, execution ports, cloud provider abstraction, and notification utilities." readme = "README.md" requires-python = ">=3.10" -license = { text = "MIT" } +license = "MIT" authors = [ { name = "QuantStrategyLab" } ] diff --git a/scripts/gate_codex_app_review.py b/scripts/gate_codex_app_review.py index f379971..66ba8d1 100644 --- a/scripts/gate_codex_app_review.py +++ b/scripts/gate_codex_app_review.py @@ -115,7 +115,7 @@ def scan_diff(diff_text: str, path_patterns: list[re.Pattern[str]]) -> list[str] if not line.startswith("+") or line.startswith("+++"): continue m = _SENSITIVE.search(line[1:]) if m: - violations.append(f"**Hardcoded secret** in `{current}`: `{m.group(0)[:100]}`") + violations.append(f"**Hardcoded secret** in `{current}`: sensitive assignment pattern detected") return list(dict.fromkeys(violations)) diff --git a/src/quant_platform_kit/notifications/_email.py b/src/quant_platform_kit/notifications/_email.py index ef8448d..8144b16 100644 --- a/src/quant_platform_kit/notifications/_email.py +++ b/src/quant_platform_kit/notifications/_email.py @@ -6,6 +6,8 @@ from collections.abc import Sequence from email.message import EmailMessage +from ._redaction import redact_sensitive_text + def parse_email_recipients(raw_value: str | Sequence[str] | None) -> tuple[str, ...]: if raw_value is None: @@ -63,5 +65,5 @@ def send_smtp_email( smtp.send_message(message) return True except Exception as exc: - printer(f"Email send failed: {exc}", flush=True) + printer(f"Email send failed: {redact_sensitive_text(exc)}", flush=True) return False diff --git a/src/quant_platform_kit/notifications/_redaction.py b/src/quant_platform_kit/notifications/_redaction.py new file mode 100644 index 0000000..7072621 --- /dev/null +++ b/src/quant_platform_kit/notifications/_redaction.py @@ -0,0 +1,26 @@ +"""Small helpers for keeping notification errors safe to log.""" + +from __future__ import annotations + +import re + + +_REDACTED = "" +_TELEGRAM_BOT_PATH_RE = re.compile(r"(?i)(/bot)([^/\s]+)") +_SENSITIVE_QUERY_RE = re.compile( + r"(?i)([?&](?:access[_-]?token|api[_-]?key|auth[_-]?token|key|password|secret|signature|token)=)([^&\s]+)" +) +_AUTH_HEADER_RE = re.compile(r"(?i)\b(Bearer|Basic)\s+([A-Za-z0-9._~+/=-]{8,})") +_ASSIGNMENT_RE = re.compile( + r"(?i)\b(api[_-]?key|auth[_-]?token|credential|password|private[_-]?key|secret|token)\s*[:=]\s*([\"']?)([^\"'\s,;]{8,})([\"']?)" +) + + +def redact_sensitive_text(value: object) -> str: + """Return text suitable for logs without exposing common secret shapes.""" + + text = str(value) + text = _TELEGRAM_BOT_PATH_RE.sub(r"\1" + _REDACTED, text) + text = _SENSITIVE_QUERY_RE.sub(r"\1" + _REDACTED, text) + text = _AUTH_HEADER_RE.sub(r"\1 " + _REDACTED, text) + return _ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}={_REDACTED}", text) diff --git a/src/quant_platform_kit/notifications/push.py b/src/quant_platform_kit/notifications/push.py index 01bb4c9..ba02d25 100644 --- a/src/quant_platform_kit/notifications/push.py +++ b/src/quant_platform_kit/notifications/push.py @@ -8,6 +8,8 @@ from email.header import Header from typing import Any +from ._redaction import redact_sensitive_text + PUSH_PROVIDER_NTFY = "ntfy" PUSH_PROVIDER_PUSHOVER = "pushover" @@ -190,7 +192,7 @@ def _request_succeeded( status = response.getcode() status = int(status) except Exception as exc: - printer(f"Push send failed for {recipient}: {exc}", flush=True) + printer(f"Push send failed for {recipient}: {redact_sensitive_text(exc)}", flush=True) return False if status < 200 or status >= 300: printer(f"Push send failed for {recipient}: HTTP {status}", flush=True) diff --git a/src/quant_platform_kit/notifications/sms.py b/src/quant_platform_kit/notifications/sms.py index 3ebb1c3..4e8c5f2 100644 --- a/src/quant_platform_kit/notifications/sms.py +++ b/src/quant_platform_kit/notifications/sms.py @@ -8,6 +8,8 @@ from collections.abc import Sequence from typing import Any +from ._redaction import redact_sensitive_text + def normalize_sms_recipient(value: str) -> str: """Normalize common US phone-number formatting to E.164. @@ -102,7 +104,7 @@ def send_twilio_sms( status = response.getcode() status = int(status) except Exception as exc: - printer(f"SMS send failed for {recipient}: {exc}", flush=True) + printer(f"SMS send failed for {recipient}: {redact_sensitive_text(exc)}", flush=True) all_sent = False continue if status < 200 or status >= 300: diff --git a/src/quant_platform_kit/notifications/strategy_plugin_email.py b/src/quant_platform_kit/notifications/strategy_plugin_email.py index 6dc9cd3..891a47d 100644 --- a/src/quant_platform_kit/notifications/strategy_plugin_email.py +++ b/src/quant_platform_kit/notifications/strategy_plugin_email.py @@ -12,6 +12,7 @@ ) from .alert_marker import CloudAlertMarkerStore, _clean_relative_key +from ._redaction import redact_sensitive_text from ._email import parse_email_recipients, send_smtp_email @@ -199,7 +200,7 @@ def publish_strategy_plugin_email_alerts( store_error = None except Exception as exc: duplicate = False - store_error = f"alert_store_check_failed:{type(exc).__name__}: {exc}" + store_error = f"alert_store_check_failed:{type(exc).__name__}: {redact_sensitive_text(exc)}" if duplicate: deliveries.append(_delivery(message, status="skipped", reason="duplicate_alert")) continue @@ -252,7 +253,7 @@ def _send_message( timeout=settings.timeout, ) except Exception as exc: - return False, f"{type(exc).__name__}: {exc}" + return False, f"{type(exc).__name__}: {redact_sensitive_text(exc)}" return bool(sent), None @@ -284,7 +285,7 @@ def _store_record_error( }, ) except Exception as exc: - return f"alert_store_record_failed:{type(exc).__name__}: {exc}" + return f"alert_store_record_failed:{type(exc).__name__}: {redact_sensitive_text(exc)}" return None @@ -350,4 +351,3 @@ def _fallback_alert_key(message: StrategyPluginAlertMessage) -> str: - diff --git a/src/quant_platform_kit/notifications/strategy_plugin_push.py b/src/quant_platform_kit/notifications/strategy_plugin_push.py index eb67d3f..2f09608 100644 --- a/src/quant_platform_kit/notifications/strategy_plugin_push.py +++ b/src/quant_platform_kit/notifications/strategy_plugin_push.py @@ -12,6 +12,7 @@ ) from .alert_marker import CloudAlertMarkerStore, _clean_relative_key +from ._redaction import redact_sensitive_text from .push import ( DEFAULT_NTFY_API_BASE_URL, DEFAULT_PUSHOVER_API_BASE_URL, @@ -182,7 +183,7 @@ def publish_strategy_plugin_push_alerts( store_error = None except Exception as exc: duplicate = False - store_error = f"alert_store_check_failed:{type(exc).__name__}: {exc}" + store_error = f"alert_store_check_failed:{type(exc).__name__}: {redact_sensitive_text(exc)}" if duplicate: deliveries.append(_delivery(message, status="skipped", reason="duplicate_alert")) continue @@ -235,7 +236,7 @@ def _send_message( timeout=settings.timeout, ) except Exception as exc: - return False, f"{type(exc).__name__}: {exc}" + return False, f"{type(exc).__name__}: {redact_sensitive_text(exc)}" return bool(sent), None @@ -275,7 +276,7 @@ def _store_record_error( }, ) except Exception as exc: - return f"alert_store_record_failed:{type(exc).__name__}: {exc}" + return f"alert_store_record_failed:{type(exc).__name__}: {redact_sensitive_text(exc)}" return None @@ -340,4 +341,3 @@ def _fallback_alert_key(message: StrategyPluginAlertMessage) -> str: - diff --git a/src/quant_platform_kit/notifications/strategy_plugin_sms.py b/src/quant_platform_kit/notifications/strategy_plugin_sms.py index 7d0122c..447a082 100644 --- a/src/quant_platform_kit/notifications/strategy_plugin_sms.py +++ b/src/quant_platform_kit/notifications/strategy_plugin_sms.py @@ -12,6 +12,7 @@ ) from .alert_marker import CloudAlertMarkerStore, _clean_relative_key +from ._redaction import redact_sensitive_text from .sms import parse_sms_recipients, send_twilio_sms _DEFAULT_SMS_PROVIDER = "twilio" @@ -171,7 +172,7 @@ def publish_strategy_plugin_sms_alerts( store_error = None except Exception as exc: duplicate = False - store_error = f"alert_store_check_failed:{type(exc).__name__}: {exc}" + store_error = f"alert_store_check_failed:{type(exc).__name__}: {redact_sensitive_text(exc)}" if duplicate: deliveries.append(_delivery(message, status="skipped", reason="duplicate_alert")) continue @@ -221,7 +222,7 @@ def _send_message( timeout=settings.timeout, ) except Exception as exc: - return False, f"{type(exc).__name__}: {exc}" + return False, f"{type(exc).__name__}: {redact_sensitive_text(exc)}" return bool(sent), None @@ -261,7 +262,7 @@ def _store_record_error( }, ) except Exception as exc: - return f"alert_store_record_failed:{type(exc).__name__}: {exc}" + return f"alert_store_record_failed:{type(exc).__name__}: {redact_sensitive_text(exc)}" return None @@ -320,4 +321,3 @@ def _fallback_alert_key(message: StrategyPluginAlertMessage) -> str: - diff --git a/src/quant_platform_kit/notifications/strategy_plugin_telegram.py b/src/quant_platform_kit/notifications/strategy_plugin_telegram.py index e85f658..4740c49 100644 --- a/src/quant_platform_kit/notifications/strategy_plugin_telegram.py +++ b/src/quant_platform_kit/notifications/strategy_plugin_telegram.py @@ -12,6 +12,7 @@ ) from .alert_marker import CloudAlertMarkerStore, _clean_relative_key +from ._redaction import redact_sensitive_text from .telegram import ( DEFAULT_TELEGRAM_BOT_API_BASE_URL, parse_telegram_chat_ids, @@ -168,7 +169,7 @@ def publish_strategy_plugin_telegram_alerts( store_error = None except Exception as exc: duplicate = False - store_error = f"alert_store_check_failed:{type(exc).__name__}: {exc}" + store_error = f"alert_store_check_failed:{type(exc).__name__}: {redact_sensitive_text(exc)}" if duplicate: deliveries.append(_delivery(message, status="skipped", reason="duplicate_alert")) continue @@ -218,7 +219,7 @@ def _send_message( timeout=settings.timeout, ) except Exception as exc: - return False, f"{type(exc).__name__}: {exc}" + return False, f"{type(exc).__name__}: {redact_sensitive_text(exc)}" return bool(sent), None @@ -258,7 +259,7 @@ def _store_record_error( }, ) except Exception as exc: - return f"alert_store_record_failed:{type(exc).__name__}: {exc}" + return f"alert_store_record_failed:{type(exc).__name__}: {redact_sensitive_text(exc)}" return None @@ -328,4 +329,3 @@ def _fallback_alert_key(message: StrategyPluginAlertMessage) -> str: - diff --git a/src/quant_platform_kit/notifications/strategy_plugin_webhook.py b/src/quant_platform_kit/notifications/strategy_plugin_webhook.py index 3de17a0..832e3b3 100644 --- a/src/quant_platform_kit/notifications/strategy_plugin_webhook.py +++ b/src/quant_platform_kit/notifications/strategy_plugin_webhook.py @@ -20,6 +20,7 @@ ) from .alert_marker import CloudAlertMarkerStore, _clean_relative_key +from ._redaction import redact_sensitive_text from .webhook import ( WEBHOOK_PROVIDER_WECOM, WEBHOOK_PROVIDER_DINGTALK, @@ -253,7 +254,7 @@ def publish_strategy_plugin_webhook_alerts( store_error = None except Exception as exc: duplicate = False - store_error = f"alert_store_check_failed:{type(exc).__name__}: {exc}" + store_error = f"alert_store_check_failed:{type(exc).__name__}: {redact_sensitive_text(exc)}" if duplicate: deliveries.append( _delivery(message, status="skipped", reason="duplicate_alert") @@ -356,7 +357,7 @@ def _send_message( timeout=settings.timeout, ) except Exception as exc: - return False, f"{type(exc).__name__}: {exc}" + return False, f"{type(exc).__name__}: {redact_sensitive_text(exc)}" return bool(sent), None @@ -396,7 +397,7 @@ def _store_record_error( }, ) except Exception as exc: - return f"alert_store_record_failed:{type(exc).__name__}: {exc}" + return f"alert_store_record_failed:{type(exc).__name__}: {redact_sensitive_text(exc)}" return None diff --git a/src/quant_platform_kit/notifications/telegram.py b/src/quant_platform_kit/notifications/telegram.py index e3bbac3..241b032 100644 --- a/src/quant_platform_kit/notifications/telegram.py +++ b/src/quant_platform_kit/notifications/telegram.py @@ -9,6 +9,8 @@ from collections.abc import Sequence from typing import Any +from ._redaction import redact_sensitive_text + DEFAULT_TELEGRAM_BOT_API_BASE_URL = "https://api.telegram.org" _TELEGRAM_MARKET_SYMBOL_LINK_RE = re.compile(r"(?= 300: printer(f"Telegram send failed for {chat_id}: HTTP {status}", flush=True) diff --git a/src/quant_platform_kit/notifications/webhook.py b/src/quant_platform_kit/notifications/webhook.py index 128fab4..45872f7 100644 --- a/src/quant_platform_kit/notifications/webhook.py +++ b/src/quant_platform_kit/notifications/webhook.py @@ -26,6 +26,8 @@ from collections.abc import Sequence from typing import Any +from ._redaction import redact_sensitive_text + # ────────────────────────────────────────────────────────────────────── # Provider constants @@ -309,7 +311,7 @@ def _json_webhook_request_succeeded( status = int(status) raw = response.read() except Exception as exc: - printer(f"{provider} webhook send failed: {exc}", flush=True) + printer(f"{provider} webhook send failed: {redact_sensitive_text(exc)}", flush=True) return False if status < 200 or status >= 300: printer(f"{provider} webhook send failed: HTTP {status}", flush=True) @@ -318,7 +320,7 @@ def _json_webhook_request_succeeded( body = json.loads(raw.decode("utf-8")) if body.get(errcode_key, -1) != 0: printer( - f"{provider} webhook send failed: {body.get(errmsg_key, 'unknown error')}", + f"{provider} webhook send failed: {redact_sensitive_text(body.get(errmsg_key, 'unknown error'))}", flush=True, ) return False diff --git a/tests/test_notification_redaction.py b/tests/test_notification_redaction.py new file mode 100644 index 0000000..5af0fb6 --- /dev/null +++ b/tests/test_notification_redaction.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import unittest + +from quant_platform_kit.notifications._redaction import redact_sensitive_text +from quant_platform_kit.notifications.telegram import send_telegram_message +from scripts import gate_codex_app_review + + +class NotificationRedactionTests(unittest.TestCase): + def test_redact_sensitive_text_masks_common_secret_shapes(self) -> None: + raw = ( + "POST https://api.telegram.org/bot123456:ABC/sendMessage" + "?access_token=test-secret-token&key=test-webhook-key " + "Authorization: Bearer testabcdefghijklmnop token='test-another-secret'" + ) + + redacted = redact_sensitive_text(raw) + + self.assertNotIn("123456:ABC", redacted) + self.assertNotIn("test-secret-token", redacted) + self.assertNotIn("test-webhook-key", redacted) + self.assertNotIn("testabcdefghijklmnop", redacted) + self.assertNotIn("test-another-secret", redacted) + self.assertIn("", redacted) + + def test_telegram_exception_logs_redacted_endpoint(self) -> None: + messages: list[str] = [] + + def opener(_request, timeout): + raise RuntimeError( + "failed https://api.telegram.org/bot123456:ABC/sendMessage?access_token=test-secret-token" + ) + + sent = send_telegram_message( + bot_token="123456:ABC", + chat_ids=("123",), + text="hello", + opener=opener, + printer=lambda *args, **_kwargs: messages.append(" ".join(str(arg) for arg in args)), + ) + + self.assertFalse(sent) + self.assertEqual(len(messages), 1) + self.assertNotIn("123456:ABC", messages[0]) + self.assertNotIn("test-secret-token", messages[0]) + self.assertIn("", messages[0]) + + def test_codex_gate_secret_diff_violation_does_not_echo_value(self) -> None: + secret_value = "super-" + "secret-token-value" + diff = "\n".join( + [ + "diff --git a/example.py b/example.py", + "+++ b/example.py", + "+token = '" + secret_value + "'", + ] + ) + + violations = gate_codex_app_review.scan_diff(diff, []) + + self.assertEqual(len(violations), 1) + self.assertNotIn(secret_value, violations[0]) + self.assertIn("sensitive assignment pattern detected", violations[0]) + + +if __name__ == "__main__": + unittest.main()