Skip to content

Commit 0eedf67

Browse files
committed
fix: harden Firstrade runtime audit gates
1 parent f675ea5 commit 0eedf67

10 files changed

Lines changed: 126 additions & 34 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ jobs:
6565
run: |
6666
set -euo pipefail
6767
python -m pip install --upgrade pip
68-
python -m pip install --upgrade pip
6968
# Install non-git packages first to avoid cross-package dependency conflicts
7069
grep -vE "^\s*(#|$)|git\+" requirements.txt | xargs -r python -m pip install -c https://raw.githubusercontent.com/QuantStrategyLab/QuantPlatformKit/main/constraints.txt
7170
# Install git-based packages individually with --no-deps
@@ -91,11 +90,16 @@ jobs:
9190
set -euo pipefail
9291
python -m pip install --no-deps -e external/QuantPlatformKit -e external/UsEquityStrategies
9392
93+
- name: Verify Python dependencies
94+
run: python -m pip check
95+
9496
- name: Run ruff
9597
run: |
9698
set -euo pipefail
9799
ruff check --exclude external .
98100
101+
- name: Run tests
102+
run: python -m pytest -q
103+
99104
- name: Check QPK pin consistency
100105
run: python scripts/check_qpk_pin_consistency.py
101-
continue-on-error: true

Dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ RUN apt-get update \
1313
COPY requirements.txt ./
1414
COPY constraints.txt ./
1515
RUN python -m pip install --upgrade pip \
16-
&& python -m pip install -r requirements.txt -c constraints.txt \
16+
&& grep -vE "^\s*(#|$)|git\+" requirements.txt | xargs -r python -m pip install -c constraints.txt \
17+
&& grep "git+" requirements.txt | while IFS= read -r pkg; do \
18+
[ -n "$pkg" ] && python -m pip install --no-deps "$pkg"; \
19+
done \
1720
&& apt-get purge -y git \
1821
&& apt-get autoremove -y --purge \
1922
&& rm -rf /var/lib/apt/lists/*

constraints.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Generated: 2026-07-01
44
# Auto-updated by update-qpk-pin.yml on every push to QPK main.
55

6-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@d786c1140967f0e96e35599d057f0655e5a9ba25
6+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b0eacd2fe4884f7f2447b704a232e9a121f396c4
77
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c8df5f9659340965bd7f53998892ed1018ed4254
88
hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@700c8b19c46336d3d8fcba687e58553afcf0235f
99
cn-equity-strategies @ git+https://github.com/QuantStrategyLab/CnEquityStrategies.git@e09fd557c2bd9ae5f4d44228915c4e52c4b0dd21

main.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import os
6+
import re
67
import traceback
78
from dataclasses import replace
89
from datetime import datetime, timezone
@@ -42,6 +43,16 @@
4243
app = Flask(__name__)
4344
register_health_endpoint(app) # GET /health /healthz
4445

46+
_REDACTED = "<redacted>"
47+
_TELEGRAM_BOT_PATH_RE = re.compile(r"(?i)(/bot)([^/\s]+)")
48+
_SENSITIVE_QUERY_RE = re.compile(
49+
r"(?i)([?&](?:access[_-]?token|api[_-]?key|auth[_-]?token|key|password|secret|signature|token)=)([^&\s]+)"
50+
)
51+
_AUTH_HEADER_RE = re.compile(r"(?i)\b(Bearer|Basic)\s+([A-Za-z0-9._~+/=-]{8,})")
52+
_ASSIGNMENT_RE = re.compile(
53+
r"(?i)\b(api[_-]?key|auth[_-]?token|credential|password|private[_-]?key|secret|token)\s*[:=]\s*([\"']?)([^\"'\s,;]{8,})([\"']?)"
54+
)
55+
4556

4657
def get_project_id() -> str | None:
4758
return os.getenv("GOOGLE_CLOUD_PROJECT")
@@ -59,6 +70,14 @@ def _split_env_list(value: str | None) -> tuple[str, ...]:
5970
)
6071

6172

73+
def redact_sensitive_text(value: object) -> str:
74+
text = str(value)
75+
text = _TELEGRAM_BOT_PATH_RE.sub(r"\1" + _REDACTED, text)
76+
text = _SENSITIVE_QUERY_RE.sub(r"\1" + _REDACTED, text)
77+
text = _AUTH_HEADER_RE.sub(r"\1 " + _REDACTED, text)
78+
return _ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}={_REDACTED}", text)
79+
80+
6281
def _get_telegram_token() -> str:
6382
try:
6483
from quant_platform_kit.cloud import get_secret_store
@@ -86,7 +105,7 @@ def _telegram_notification_targets() -> tuple[tuple[str, str], ...]:
86105

87106

88107
def _runtime_error_notification_message(exc: Exception) -> str:
89-
error_text = f"{type(exc).__name__}: {exc}"
108+
error_text = _safe_exception_text(exc, include_type=True)
90109
if len(error_text) > 1200:
91110
error_text = error_text[:1197] + "..."
92111
is_health_check = request.path == "/probe"
@@ -131,13 +150,22 @@ def _notify_runtime_error(exc: Exception) -> bool:
131150
try:
132151
build_sender(token, chat_id)(message)
133152
except Exception as send_exc: # pragma: no cover - build_sender normally handles this.
134-
print(f"Firstrade runtime error Telegram send failed: {send_exc}", flush=True)
153+
print(
154+
f"Firstrade runtime error Telegram send failed: {redact_sensitive_text(send_exc)}",
155+
flush=True,
156+
)
135157
return attempted
136158

137159

160+
def _safe_exception_text(exc: Exception, *, include_type: bool = False) -> str:
161+
text = redact_sensitive_text(exc)
162+
return f"{type(exc).__name__}: {text}" if include_type else text
163+
164+
138165
def _handle_strategy_run_exception(exc: Exception) -> bool:
139-
print(f"Firstrade strategy run failed: {type(exc).__name__}: {exc}", flush=True)
140-
traceback.print_exc()
166+
print(f"Firstrade strategy run failed: {_safe_exception_text(exc, include_type=True)}", flush=True)
167+
for line in traceback.format_exception(type(exc), exc, exc.__traceback__):
168+
print(redact_sensitive_text(line.rstrip()), flush=True)
141169
return _notify_runtime_error(exc)
142170

143171

@@ -412,7 +440,7 @@ def smoke():
412440
}
413441
)
414442
except FirstradePlatformError as exc:
415-
return jsonify({"ok": False, "error": str(exc)}), 500
443+
return jsonify({"ok": False, "error": _safe_exception_text(exc)}), 500
416444

417445

418446
def session_check():
@@ -437,7 +465,7 @@ def session_check():
437465
jsonify(
438466
{
439467
"ok": False,
440-
"error": str(exc),
468+
"error": _safe_exception_text(exc),
441469
"runtime_error_notification_attempted": notification_attempted,
442470
}
443471
),
@@ -449,7 +477,7 @@ def session_check():
449477
jsonify(
450478
{
451479
"ok": False,
452-
"error": f"{type(exc).__name__}: {exc}",
480+
"error": _safe_exception_text(exc, include_type=True),
453481
"runtime_error_notification_attempted": notification_attempted,
454482
}
455483
),
@@ -487,7 +515,7 @@ def run_strategy():
487515
jsonify(
488516
{
489517
"ok": False,
490-
"error": str(exc),
518+
"error": _safe_exception_text(exc),
491519
"runtime_error_notification_attempted": notification_attempted,
492520
}
493521
),
@@ -499,7 +527,7 @@ def run_strategy():
499527
jsonify(
500528
{
501529
"ok": False,
502-
"error": f"{type(exc).__name__}: {exc}",
530+
"error": _safe_exception_text(exc, include_type=True),
503531
"runtime_error_notification_attempted": notification_attempted,
504532
}
505533
),
@@ -527,7 +555,7 @@ def dry_run():
527555
jsonify(
528556
{
529557
"ok": False,
530-
"error": str(exc),
558+
"error": _safe_exception_text(exc),
531559
"runtime_error_notification_attempted": notification_attempted,
532560
}
533561
),
@@ -539,7 +567,7 @@ def dry_run():
539567
jsonify(
540568
{
541569
"ok": False,
542-
"error": f"{type(exc).__name__}: {exc}",
570+
"error": _safe_exception_text(exc, include_type=True),
543571
"runtime_error_notification_attempted": notification_attempted,
544572
}
545573
),
@@ -566,7 +594,7 @@ def monitor_dispatch():
566594
jsonify(
567595
{
568596
"ok": False,
569-
"error": f"{type(exc).__name__}: {exc}",
597+
"error": _safe_exception_text(exc, include_type=True),
570598
"runtime_error_notification_attempted": notification_attempted,
571599
}
572600
),

notifications/telegram.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,19 +1203,29 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
12031203
account = str(result.get("account") or "").strip()
12041204
lines = [translator("rebalance_title" if has_rebalance_attempt else "heartbeat_title")]
12051205
if strategy_name:
1206-
lines.append(f"策略: {strategy_name} | 账户: {account}" if lang == "zh" else f"Strategy: {strategy_name} | Account: {account}")
1206+
lines.append(translator("strategy_label", name=strategy_name))
1207+
if account:
1208+
lines.append(translator("account_label", account=account))
12071209
if dry_run_only:
12081210
lines.append(translator("dry_run_banner"))
12091211
if bool(result.get("execution_blocked")):
12101212
blocked = list(result.get("execution_blocking_skips") or skipped)
12111213
reason = _format_skipped_reason(blocked, translator=translator)
1212-
lines.append(translator("execution_blocked_banner", reason=reason))
1214+
if bool(result.get("funding_blocked")):
1215+
banner_key = "funding_blocked_banner"
1216+
elif bool(result.get("execution_block_retryable")):
1217+
banner_key = "execution_blocked_retryable_banner"
1218+
else:
1219+
banner_key = "execution_blocked_banner"
1220+
lines.append(translator(banner_key, reason=reason))
12131221

12141222
dashboard_lines = _format_dashboard_lines(portfolio, execution, translator=translator)
12151223
if dashboard_lines:
12161224
lines.append(SEPARATOR)
12171225
lines.extend(dashboard_lines)
12181226
lines.append(SEPARATOR)
1227+
lines.extend(_format_timing_lines(execution, translator=translator))
1228+
lines.extend(_format_signal_lines(execution, translator=translator))
12191229
lines.extend(target_diff_lines)
12201230
lines.extend(
12211231
str(line).strip()

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ pythonpath = [
4040
testpaths = ["tests"]
4141

4242
[tool.ruff]
43-
ignore = ["E701", "E702", "E741", "F841", "F821", "B008", "F401", "F601", "E402", "E401"]
4443
target-version = "py311"
4544
line-length = 100
4645

46+
[tool.ruff.lint]
47+
ignore = ["E701", "E702", "E741", "F841", "F821", "B008", "F401", "F601", "E402", "E401"]
48+
4749
[tool.ruff.lint.per-file-ignores]
4850
"tests/**" = ["E402", "F841"]
4951

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ pandas_market_calendars
77
pytest
88
pytz
99
requests
10-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@d786c1140967f0e96e35599d057f0655e5a9ba25
10+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b0eacd2fe4884f7f2447b704a232e9a121f396c4
1111
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c8df5f9659340965bd7f53998892ed1018ed4254

runtime_config_support.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,16 @@
4141
IBIT_SMART_DCA_PROFILE = "ibit_smart_dca"
4242

4343

44-
def _get_credential(secret_name: str, env_var: str) -> str:
44+
def _get_credential(secret_name: str, env_var: str) -> str | None:
45+
val = os.environ.get(env_var)
46+
if val is not None:
47+
return val
4548
try:
4649
from quant_platform_kit.cloud import get_secret_store
4750

4851
return get_secret_store().get_secret(secret_name, project_id="firstradequant")
4952
except Exception:
50-
val = os.environ.get(env_var)
51-
if val is None:
52-
raise ValueError(f"Missing credential: {secret_name} (tried env {env_var})")
53-
return val
53+
return None
5454

5555

5656
@dataclass(frozen=True)
@@ -218,19 +218,19 @@ def load_platform_runtime_settings(
218218
debug_position_snapshot=resolve_bool_value(os.getenv("FIRSTRADE_DEBUG_POSITION_SNAPSHOT")),
219219
income_threshold_usd=resolve_optional_float_env(os.environ, "INCOME_THRESHOLD_USD"),
220220
qqqi_income_ratio=_qqqi_income_ratio_env(),
221-
income_layer_enabled=resolve_optional_bool_env("INCOME_LAYER_ENABLED"),
221+
income_layer_enabled=_optional_bool_env("INCOME_LAYER_ENABLED"),
222222
income_layer_start_usd=_optional_non_negative_float_env("INCOME_LAYER_START_USD"),
223223
income_layer_max_ratio=resolve_optional_ratio_env("INCOME_LAYER_MAX_RATIO"),
224224
dca_mode=resolve_optional_dca_mode_env("DCA_MODE"),
225225
dca_base_investment_usd=resolve_optional_positive_float_env("DCA_BASE_INVESTMENT_USD"),
226-
ibit_zscore_exit_enabled=resolve_optional_bool_env("IBIT_ZSCORE_EXIT_ENABLED"),
226+
ibit_zscore_exit_enabled=_optional_bool_env("IBIT_ZSCORE_EXIT_ENABLED"),
227227
ibit_zscore_exit_mode=resolve_optional_ibit_zscore_exit_mode_env("IBIT_ZSCORE_EXIT_MODE"),
228228
ibit_zscore_exit_parking_symbol=resolve_optional_symbol_env("IBIT_ZSCORE_EXIT_PARKING_SYMBOL"),
229229
ibit_zscore_exit_risk_reduced_exposure=resolve_optional_ratio_env(
230230
"IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE"
231231
),
232232
ibit_zscore_exit_risk_off_exposure=resolve_optional_ratio_env("IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE"),
233-
ibit_zscore_exit_allow_outside_execution_window=resolve_optional_bool_env(
233+
ibit_zscore_exit_allow_outside_execution_window=_optional_bool_env(
234234
"IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW"
235235
),
236236
runtime_execution_window_trading_days=_runtime_execution_window_trading_days_env(
@@ -384,10 +384,16 @@ def _qqqi_income_ratio_env() -> float | None:
384384

385385

386386
def _runtime_target_enabled_env() -> bool:
387-
value = resolve_optional_bool_env("RUNTIME_TARGET_ENABLED")
387+
value = _optional_bool_env("RUNTIME_TARGET_ENABLED")
388388
return True if value is None else value
389389

390390

391+
def _optional_bool_env(name: str) -> bool | None:
392+
raw_value = os.getenv(f"QSL_{name}") or os.getenv(name)
393+
if raw_value is None or str(raw_value).strip() == "":
394+
return None
395+
return resolve_optional_bool_env(name)
396+
391397

392398
def _optional_non_negative_float_env(name: str) -> float | None:
393399
value = resolve_optional_float_env(os.environ, name)

scripts/check_qpk_pin_consistency.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
"""Check that all QPK git references match the canonical QPK_PIN.
33
Usage: python scripts/check_qpk_pin_consistency.py [--fix]
44
"""
5-
import re, subprocess, sys
5+
import re, sys
66
from pathlib import Path
77

88
QPK_PIN_URL = "https://raw.githubusercontent.com/QuantStrategyLab/QuantPlatformKit/main/QPK_PIN"
9-
SHA_RE = re.compile(r"@([a-f0-9]{40})")
9+
QPK_REF_RE = re.compile(r"QuantPlatformKit\.git@([a-f0-9]{40})")
1010

1111
def fetch_pin() -> str:
1212
import urllib.request
@@ -23,15 +23,17 @@ def main():
2323
for path in sorted(Path.cwd().glob("**/requirements*.txt")) + sorted(Path.cwd().glob("**/pyproject.toml")):
2424
if "external" in str(path): continue
2525
content = path.read_text()
26-
for m in SHA_RE.finditer(content):
26+
updated = content
27+
for m in QPK_REF_RE.finditer(content):
2728
sha = m.group(1)
28-
if "QuantPlatformKit" not in content[max(0,m.start()-200):m.end()]: continue
2929
if sha != target:
3030
errors += 1
3131
print(f" ❌ {path}: QPK@{sha[:12]} (expected {target[:12]})")
3232
if fix:
33-
path.write_text(content.replace(sha, target))
33+
updated = updated.replace(f"QuantPlatformKit.git@{sha}", f"QuantPlatformKit.git@{target}")
3434
print(" → fixed")
35+
if fix and updated != content:
36+
path.write_text(updated)
3537
if errors:
3638
print(f"\n{errors} mismatch(es). Run with --fix to auto-fix.")
3739
return 1

tests/test_request_handling.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ def route_methods():
2424
def test_cloud_run_route_contracts_are_registered():
2525
assert route_methods() == {
2626
"/": ["GET"],
27+
"/health": ["GET"],
28+
"/healthz": ["GET"],
2729
"/profiles": ["GET"],
2830
"/smoke": ["GET"],
2931
"/run": ["GET", "POST"],
@@ -288,6 +290,41 @@ def send(message):
288290
assert "错误: ValueError: snapshot denied" in text
289291

290292

293+
def test_run_endpoint_redacts_sensitive_error_text(monkeypatch):
294+
monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true")
295+
monkeypatch.setenv("TELEGRAM_TOKEN", "token-1")
296+
monkeypatch.setenv("GLOBAL_TELEGRAM_CHAT_ID", "chat-1")
297+
sent_messages = []
298+
299+
def fake_build_sender(token, chat_id):
300+
def send(message):
301+
sent_messages.append((token, chat_id, message))
302+
303+
return send
304+
305+
sensitive_error = (
306+
"request failed: password=supersecret123 token=abcd1234efgh "
307+
"https://api.telegram.org/bot123456789:ABC/sendMessage?api_key=key987654"
308+
)
309+
monkeypatch.setattr(main, "build_sender", fake_build_sender)
310+
monkeypatch.setattr(
311+
main,
312+
"_run_strategy_cycle_with_report",
313+
lambda: (_ for _ in ()).throw(RuntimeError(sensitive_error)),
314+
)
315+
client = main.app.test_client()
316+
317+
response = client.post("/run")
318+
319+
assert response.status_code == 500
320+
payload = response.get_json()
321+
assert "<redacted>" in payload["error"]
322+
assert "<redacted>" in sent_messages[0][2]
323+
for raw_secret in ("supersecret123", "abcd1234efgh", "123456789:ABC", "key987654"):
324+
assert raw_secret not in payload["error"]
325+
assert raw_secret not in sent_messages[0][2]
326+
327+
291328
def test_run_endpoint_error_does_not_require_telegram_config(monkeypatch):
292329
monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true")
293330
monkeypatch.delenv("TELEGRAM_TOKEN", raising=False)

0 commit comments

Comments
 (0)