Skip to content

Commit c7fc72c

Browse files
Pigbibicodex
andcommitted
fix: reject secrets in service target inventory
Co-Authored-By: Codex <noreply@openai.com>
1 parent c55ebdd commit c7fc72c

4 files changed

Lines changed: 61 additions & 2 deletions

File tree

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ confirm_apply=APPLY_AND_SYNC
8989
- 多服务目标以 `service_name` 为唯一主标识;同一 `account_scope` 可以有多个策略服务,切换只更新精确服务,不会覆盖兄弟目标。
9090
- `CLOUD_RUN_SERVICE_TARGETS_JSON` 同时支持数组和 `{targets:[...]}`;新增服务必须显式选择 `service_targets_mode=allow_create`
9191
- 跨仓写 variables 和触发 workflow 必须在本仓配置 `RUNTIME_SETTINGS_GH_TOKEN` secret,token 至少需要目标仓库的 variables/workflow 写权限;不会回退到默认 `github.token` 写远端变量。
92-
- `RUNTIME_TARGET_JSON` 是经过 schema 校验的非敏感部署意图,控制台会写入 GitHub Variable;下游平台应优先读取该 Variable,旧同名 Secret 仅可作为迁移回退。它不得包含券商凭据、账户密码或 API key。
92+
- `RUNTIME_TARGET_JSON` 与多服务清单 `CLOUD_RUN_SERVICE_TARGETS_JSON` 都是经过 schema 校验的非敏感部署意图,控制台会写入 GitHub Variable;下游平台应优先读取这些 Variable,旧同名 Secret 仅可作为迁移回退。两者都不得包含券商凭据、账户密码或 API key;校验器也会拒绝嵌套的疑似 secret 字段
9393
- LongBridge、IBKR、Schwab、Firstrade 的 `service_targets_mode=auto` 会检查目标仓库是否已有多服务清单,因此即使只做 preview 也需要 `RUNTIME_SETTINGS_GH_TOKEN`
9494
- Binance 运行在 Oracle Cloud VPS 的 self-hosted runner。仓库变量会在外部调度器下一次触发 `main.yml` 时被读取;中控不会自动触发该运行 workflow,因为它可能直接执行实盘。切换到不同运行频率的策略时,还必须单独复核 VPS 外部调度器。
9595
- QMT 当前仅支持 dry-run,尚无实盘部署配置;可以生成目标并暂存仓库变量,但会拒绝 `trigger_platform_sync=true`

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
QuantRuntimeSettings is a **config-driven** runtime settings package that serves as the central control plane for QuantStrategyLab deployments. It defines versioned strategy-to-platform assignments and hosts a Cloudflare Workers-based console. The console is evolving into the personal deployment's single human-facing global decision surface; see [Unified Control Console V1](qsl_unified_control_console_architecture_v1.zh-CN.md). It remains separate from broker credentials and execution.
66

7-
The generated `RUNTIME_TARGET_JSON` payload is the canonical desired-state contract for one deployment target. `scheduler`, `market`, `market_calendar`, `market_timezone`, and plugin mount outputs are derived from `strategy_profile`, while `execution_mode` is validated against strategy-profile policy. It is validated, non-secret deployment metadata and is written as a GitHub Variable so the console can reconcile desired state; it must never contain credentials or account secrets. Platform consumers must prefer that Variable and may read a legacy Secret only as a temporary migration fallback.
7+
The generated `RUNTIME_TARGET_JSON` payload is the canonical desired-state contract for one deployment target. `scheduler`, `market`, `market_calendar`, `market_timezone`, and plugin mount outputs are derived from `strategy_profile`, while `execution_mode` is validated against strategy-profile policy. It and the repository-level `CLOUD_RUN_SERVICE_TARGETS_JSON` inventory are validated, non-secret deployment metadata written as GitHub Variables so the console can reconcile desired state; neither may contain credentials or account secrets. Platform consumers must prefer those Variables and may read legacy Secrets only as temporary migration fallbacks.
88

99
### Multi-strategy identity and storage
1010

python/scripts/runtime_settings.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,32 @@ def validate_option_overlay_variables(extra_variables: dict[str, Any], errors: l
892892
errors.append("extra_variables.OPTION_OVERLAY_ENABLED is false but an option overlay family is enabled")
893893

894894

895+
def validate_nonsecret_service_target_inventory(value: Any, *, path: str, errors: list[str]) -> None:
896+
"""Reject secret-shaped keys nested in a variable-backed service inventory."""
897+
payload = value
898+
if isinstance(payload, str):
899+
try:
900+
payload = json.loads(payload)
901+
except json.JSONDecodeError:
902+
return
903+
904+
def visit(node: Any, current_path: str) -> None:
905+
if isinstance(node, dict):
906+
for key, nested in node.items():
907+
key_text = str(key)
908+
nested_path = f"{current_path}.{key_text}"
909+
if is_secret_variable_name(key_text):
910+
errors.append(f"{nested_path} looks like a secret and must not be stored here")
911+
continue
912+
visit(nested, nested_path)
913+
elif isinstance(node, list):
914+
for index, nested in enumerate(node):
915+
visit(nested, f"{current_path}[{index}]")
916+
917+
if isinstance(payload, (dict, list)):
918+
visit(payload, path)
919+
920+
895921
def validate_extra_variables(target: dict[str, Any], errors: list[str]) -> None:
896922
extra_variables = target.get("extra_variables", {})
897923
if not isinstance(extra_variables, dict):
@@ -912,6 +938,10 @@ def validate_extra_variables(target: dict[str, Any], errors: list[str]) -> None:
912938
errors.append(f"extra_variables.{name} looks like a secret and must not be stored here")
913939
if isinstance(value, str) and "\n" in value:
914940
errors.append(f"extra_variables.{name} must be a single-line value")
941+
if name == "CLOUD_RUN_SERVICE_TARGETS_JSON":
942+
validate_nonsecret_service_target_inventory(
943+
value, path=f"extra_variables.{name}", errors=errors
944+
)
915945

916946
validate_option_overlay_variables(extra_variables, errors)
917947

@@ -945,6 +975,10 @@ def validate_repository_variables(target: dict[str, Any], errors: list[str]) ->
945975
errors.append(f"repository_variables.{name} looks like a secret and must not be stored here")
946976
if isinstance(value, str) and "\n" in value:
947977
errors.append(f"repository_variables.{name} must be a single-line value")
978+
if name == "CLOUD_RUN_SERVICE_TARGETS_JSON":
979+
validate_nonsecret_service_target_inventory(
980+
value, path=f"repository_variables.{name}", errors=errors
981+
)
948982

949983

950984
def validate_target(target: dict[str, Any], path: Path | None = None) -> list[str]:

python/tests/test_runtime_settings.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,6 +1316,31 @@ def test_extra_variables_reject_secret_values_but_allow_secret_pointers(self):
13161316
errors,
13171317
)
13181318

1319+
def test_service_target_inventory_rejects_nested_secret_values(self):
1320+
_, target = self.load_target("examples/targets/longbridge/sg.example.json")
1321+
target["repository_variables"] = {
1322+
"CLOUD_RUN_SERVICE_TARGETS_JSON": {
1323+
"targets": [
1324+
{
1325+
"service": "longbridge-quant-sg-service",
1326+
"BROKER_PASSWORD": "not-allowed",
1327+
"LONGPORT_SECRET_NAME": "allowed-secret-manager-name",
1328+
}
1329+
]
1330+
}
1331+
}
1332+
1333+
errors = runtime_settings.validate_target(target)
1334+
1335+
self.assertIn(
1336+
"repository_variables.CLOUD_RUN_SERVICE_TARGETS_JSON.targets[0].BROKER_PASSWORD looks like a secret and must not be stored here",
1337+
errors,
1338+
)
1339+
self.assertNotIn(
1340+
"repository_variables.CLOUD_RUN_SERVICE_TARGETS_JSON.targets[0].LONGPORT_SECRET_NAME looks like a secret and must not be stored here",
1341+
errors,
1342+
)
1343+
13191344
def test_longbridge_dry_run_flag_must_match_runtime_target(self):
13201345
_, target = self.load_target("examples/targets/longbridge/sg.example.json")
13211346
target["extra_variables"]["LONGBRIDGE_DRY_RUN_ONLY"] = "true"

0 commit comments

Comments
 (0)