Skip to content
Draft
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
41 changes: 41 additions & 0 deletions .github/workflows/_backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,42 @@ jobs:
- name: Build with Maven
run: cd services && ./mvnw compile -B

- name: Verify reproducible patched Nacos generation
run: cd services && ./mvnw -B -pl platform/nacos-api-patched -Pverify-nacos-generation verify

- name: Verify upstream Nacos SBOM identity
run: |
python3 -m unittest discover -s scripts/test -p test_nacos_sbom.py -v
python3 scripts/ci/check-nacos-sbom.py services/platform/nacos-api-patched/src/main/resources/META-INF/nacos-patch/bom.json

- name: Scan upstream Nacos SBOM identity
id: nacos-sbom-scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 (Trivy v0.74.0)
with:
scan-type: sbom
list-all-pkgs: 'true'
scan-ref: services/platform/nacos-api-patched/src/main/resources/META-INF/nacos-patch/bom.json
scanners: vuln
vuln-type: library
severity: HIGH,CRITICAL
exit-code: '1'
format: json
output: ${{ runner.temp }}/nacos-api-pedigree.trivy.json
version: v0.74.0

- name: Verify Trivy recognized upstream Nacos
if: ${{ always() && steps.nacos-sbom-scan.outcome != 'skipped' && steps.nacos-sbom-scan.outcome != '' }}
run: python3 scripts/ci/check-nacos-sbom.py services/platform/nacos-api-patched/src/main/resources/META-INF/nacos-patch/bom.json --report "$RUNNER_TEMP/nacos-api-pedigree.trivy.json"

- name: Upload upstream Nacos scan inventory
if: ${{ always() && steps.nacos-sbom-scan.outcome != 'skipped' && steps.nacos-sbom-scan.outcome != '' }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: nacos-api-pedigree-scan
path: ${{ runner.temp }}/nacos-api-pedigree.trivy.json
if-no-files-found: error
retention-days: 30

backend-test:
name: Backend Test
needs: backend-build
Expand Down Expand Up @@ -96,6 +132,11 @@ jobs:
- name: Run tests with CI profile
run: cd services && ./mvnw verify -Dspring.profiles.active=ci -Dtest='!*IT' -B

- name: Verify patched client against disposable authenticated Nacos
run: |
python3 -m unittest discover -s scripts/test -p test_nacos_client_integration.py -v
bash scripts/test/nacos-client-integration.sh

- name: Upload test results on failure
if: failure()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
Expand Down
6 changes: 6 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,12 @@ WebSocket 只从 handshake 的 `access_token` cookie 获取 token,不接受 qu

Redis ACL deny-by-default,按 Owner 限制命令、key 和 channel;运行时 ACL 在 ignored directory 原子物化,轮换使用 overlap/finalize/rollback。命令白名单授予 `+scan` 而不授予 `+keys`,因此 Spring Cache 的 Redis writer 必须使用 SCAN 批量策略(共享 `RedisCacheWritePolicy`);默认的 `KEYS` writer 会让 `@CacheEvict(allEntries = true)` 与 `Cache#clear()` 以 `NOPERM` 失败。生产 Compose 不挂载 Docker socket;Judge 使用部署拥有的 remote/rootless Docker TLS endpoint、只读证书和共享 workspace。真实证书、私钥、密码、token、生产 endpoint 和 secret-store 状态不得提交或写入文档。

### 注册中心协议解析边界

应用使用独立坐标的 Nacos API 衍生包修复客户端 protobuf 生成代码,仍保留上游来源和版本血缘;构建及兼容门禁见 [开发指南](DEVELOPMENT.md#nacos-客户端协议构建与兼容验证)。客户端包与独立 Nacos 服务端镜像是不同安全边界。服务端 gRPC 解码发生在请求鉴权之前,开启鉴权不能代替解析器修复;私有网络和开发 loopback 限制暴露面,但不能保护同网络或本机可达路径。服务端开放问题单独记录在 [Services issue registry](../services/docs/SERVICES_ISSUES.md),不得把客户端测试成功表述成服务端漏洞已修复。

配置监听的协议兼容与 workload 最小权限属于不同验证边界。独占合成数据 namespace 的测试账号可以按服务端接口要求拥有该 namespace 的配置读取权限,但不得把这一测试授权推广为生产权限或生产兼容证据;原 workload 监听限制见 [SVC-030](../services/docs/SERVICES_ISSUES.md)。

### 证据入口

- [Security invariants](../AGENTS.md#security-invariants)
Expand Down
23 changes: 23 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,29 @@ CI/CD 契约的 provenance 测试数据必须由测试在临时目录生成或

这些检查验证流水线实现与离线失败路径,不代表已运行远程 GitHub Actions、签名发布或真实环境部署。Environment 审批、分支保护和部署凭据的启用要求见 [`OPERATIONS.md`](OPERATIONS.md#cicd-入口)。

### Nacos 客户端协议构建与兼容验证

`services/platform/nacos-api-patched` 以独立的 `com.ulticode:nacos-api-patched` 坐标构建客户端 API 衍生包,不发布到上游坐标或覆盖上游 artifact。构建校验锁定的原始 JAR、POM、协议和生成源码 SHA-256,仅替换 protobuf 生成类,保留其他上游字节;包内保留原始来源、许可证和 CycloneDX 血缘;CI 额外扫描上游普通组件并核对 Trivy 实际识别结果,不能因为改用自有坐标而丢失扫描覆盖。普通构建编译已提交的生成源码,不需要本机 protoc。更改协议或工具链时必须同时更新来源、生成结果和校验值,不得把不匹配改为警告。

```bash
# Linux x86_64 上再生成并逐字节校验;其他已锁定平台用 nacos.protoc.classifier 选择。
(cd services && ./mvnw -B -pl platform/nacos-api-patched -Pverify-nacos-generation verify)
# 严格解析与类来源单测;不启动基础设施。
(cd services && ./mvnw -B -pl platform/rpc-resilience -am -Punit verify)
# Docker Compose 本机隔离集成;无需已有 .env,不访问现有实例。
bash scripts/test/nacos-client-integration.sh
# 编排失败与清理路径的零基础设施回归。
python3 scripts/test/test_nacos_client_integration.py
```

再生成使用锁定的 protoc 版本,避免破坏已编译 Nacos/gRPC 调用者的方法签名;不能只按运行时版本号选择最新生成器。单测与真实客户端均启用 `com.google.protobuf.error_on_unsafe_pre22_gencode`,禁止用 `use_unsafe_pre22_gencode` 隐藏旧代码。集成脚本生成临时凭据、独立 Compose project 与 loopback 端口,验证注册、发现、配置/元数据回调、错误及缺失凭据拒绝、断线检测与自动重连/重订阅,并清理所属容器、卷和私有日志。它拒绝远端 Docker daemon;容器或工具不可用时失败,不跳过。后端 CI 同时要求再生成校验和真实注册中心集成通过。

配置监听验证分为两个不可替代的对照:原 AUTH workload ACL 不变,继续验证 naming 与直接配置读写,并要求其标准批量监听请求明确返回 403;另一个随机凭据的测试账号只在独占、仅含合成数据的 namespace 内拥有配置读取权限和固定 dataId 写权限,使用普通 SDK listener 验证变更回调与重连。该读取权限覆盖测试 namespace 的所有 group,不是“仅监听”权限;不得复制到生产。跨 namespace 的直接读取和范围外写入仍须拒绝,测试账号、数据与资源随 Compose project 销毁。

生命周期检查在关闭前捕获原 RPC、通道和 SDK 声明关闭的执行器,验证其终止及 factory 移除;关闭后调用配置客户端的 health getter 可能重新创建连接,不能用作无副作用的关闭断言。上游 listener 同步线程池的保留须单独记录,不能把临时 JVM 退出说成 SDK 完整释放。

此补丁只覆盖应用进程加载的 Nacos 客户端 API,不改变独立 Nacos 服务端镜像、生产 workload ACL 或 SDK 生命周期实现。测试专用授权下的回调通过不代表原生产角色的监听兼容;服务端解析器风险、生产监听限制与 listener 线程池保留分别见 [SVC-029 / SVC-030 / SVC-031](../services/docs/SERVICES_ISSUES.md)。

### Core 与 distributed 验证边界

| 层级 | 环境/副作用 | 能证明什么 | 明确不能证明 |
Expand Down
43 changes: 43 additions & 0 deletions scripts/ci/check-nacos-sbom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Fail closed when the upstream Nacos identity is absent from SBOM/scan inventory."""
import argparse
import json
from pathlib import Path
import sys

UPSTREAM = "pkg:maven/com.alibaba.nacos/nacos-api@2.5.1"
PATCHED = "pkg:maven/com.ulticode/nacos-api-patched@2.5.1-ulticode-1"


def validate(sbom, report=None):
component = next((item for item in sbom.get("components", []) if item.get("purl") == UPSTREAM), None)
if component is None or (component.get("group"), component.get("name"), component.get("version")) != (
"com.alibaba.nacos", "nacos-api", "2.5.1"):
raise ValueError("Ordinary SBOM components must identify upstream com.alibaba.nacos:nacos-api:2.5.1")
if not any(item.get("ref") == PATCHED and component.get("bom-ref") in item.get("dependsOn", [])
for item in sbom.get("dependencies", [])):
raise ValueError("Patched SBOM component must depend on the upstream identity")
if report is not None:
packages = [package for result in report.get("Results", []) for package in result.get("Packages", [])]
if not any(package.get("Identifier", {}).get("PURL") == UPSTREAM or (
package.get("Name") == "com.alibaba.nacos:nacos-api" and package.get("Version") == "2.5.1")
for package in packages):
raise ValueError("Trivy package inventory did not identify upstream Nacos; no findings is not sufficient")


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("sbom", type=Path)
parser.add_argument("--report", type=Path)
args = parser.parse_args()
try:
validate(json.loads(args.sbom.read_text()), json.loads(args.report.read_text()) if args.report else None)
except (OSError, ValueError, TypeError, AttributeError) as error:
print(str(error), file=sys.stderr)
return 1
print("Nacos upstream SBOM identity" + (" and Trivy recognition" if args.report else "") + ": PASS")
return 0


if __name__ == "__main__":
sys.exit(main())
28 changes: 28 additions & 0 deletions scripts/ci/check-workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,34 @@ def check_steps(steps, context):
steps = jobs.get("ci-ok", {}).get("steps", [])
if not any('needs.changes.outputs.acceptance' in str(step.get('env', {}).get('SELECTED', '')) for step in steps):
reject("ci-ok must validate the acceptance selection")
if path.name == "_backend.yml":
build = data.get("jobs", {}).get("backend-build", {})
steps = build.get("steps", [])
scans = [step for step in steps if step.get("id") == "nacos-sbom-scan"]
expected_scan = {
"scan-type": "sbom", "list-all-pkgs": "true",
"scan-ref": "services/platform/nacos-api-patched/src/main/resources/META-INF/nacos-patch/bom.json",
"scanners": "vuln", "vuln-type": "library", "severity": "HIGH,CRITICAL",
"exit-code": "1", "format": "json", "version": "v0.74.0",
"output": "${{ runner.temp }}/nacos-api-pedigree.trivy.json",
}
if (len(scans) != 1 or build.get("if") is not None
or scans[0].get("if") is not None
or scans[0].get("uses") != "aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25"
or any(scans[0].get("with", {}).get(key) != value for key, value in expected_scan.items())
or scans[0].get("with", {}).get("ignore-unfixed") not in (None, False, "false")):
reject("upstream Nacos SBOM scan must remain pinned, unconditional and fail closed")
scan_ran = "${{ always() && steps.nacos-sbom-scan.outcome != 'skipped' && steps.nacos-sbom-scan.outcome != '' }}"
if not any(step.get("if") == scan_ran and
'check-nacos-sbom.py' in step.get("run", "") and
'--report "$RUNNER_TEMP/nacos-api-pedigree.trivy.json"' in step.get("run", "")
for step in steps):
reject("upstream Nacos scan must verify actual scanner package recognition")
if not any(step.get("if") == scan_ran and
step.get("uses", "").startswith("actions/upload-artifact@") and
step.get("with", {}).get("path") == "${{ runner.temp }}/nacos-api-pedigree.trivy.json" and
step.get("with", {}).get("if-no-files-found") == "error" for step in steps):
reject("upstream Nacos scan inventory must be retained on success and failure")
for name, job in data.get("jobs", {}).items():
if "permissions" in job:
check_permissions(job["permissions"], WRITE_PERMISSIONS.get((path.name, name), set()), f"job {name} permissions")
Expand Down
55 changes: 55 additions & 0 deletions scripts/test/fixtures/nacos-client.compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Test-only project. Never load application .env or attach application volumes.
services:
mysql:
image: mysql@sha256:0255b469f0135a0236d672d60e3154ae2f4538b146744966d96440318cc822c6
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?disposable password required}
volumes:
- ${NACOS_FIXTURE_SCHEMA:?schema path required}:/docker-entrypoint-initdb.d/01-nacos-init.sql:ro
healthcheck:
test: ["CMD-SHELL", 'MYSQL_PWD="$${MYSQL_ROOT_PASSWORD}" mysql -uroot nacos_config -e "SELECT 1 FROM users LIMIT 1" >/dev/null 2>&1']
interval: 2s
timeout: 5s
retries: 60
networks: [fixture]

nacos:
# Same v2.3.2 server artifact as production. This gate validates CLIENT
# compatibility only; it does not remediate the server's embedded libraries.
image: nacos/nacos-server@sha256:d70b20546fe59aeb86add0754db1dbc3a3067363e18599329c5163d0d8cd9cd8
platform: linux/amd64
environment:
MODE: standalone
SPRING_DATASOURCE_PLATFORM: mysql
MYSQL_SERVICE_HOST: mysql
MYSQL_SERVICE_PORT: 3306
MYSQL_SERVICE_DB_NAME: nacos_config
MYSQL_SERVICE_USER: root
MYSQL_SERVICE_PASSWORD: ${MYSQL_ROOT_PASSWORD:?disposable password required}
MYSQL_SERVICE_DB_PARAM: "characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useSSL=false&allowPublicKeyRetrieval=true"
JVM_XMS: 256m
JVM_XMX: 512m
JVM_XMN: 128m
NACOS_AUTH_ENABLE: "true"
NACOS_AUTH_TOKEN: ${NACOS_AUTH_TOKEN:?disposable token required}
NACOS_AUTH_IDENTITY_KEY: ${NACOS_AUTH_IDENTITY_KEY:?disposable identity required}
NACOS_AUTH_IDENTITY_VALUE: ${NACOS_AUTH_IDENTITY_VALUE:?disposable identity required}
ports:
- "127.0.0.1:${NACOS_FIXTURE_HTTP_PORT:?loopback port required}:8848"
- "127.0.0.1:${NACOS_FIXTURE_GRPC_PORT:?loopback port required}:9848"
depends_on:
mysql:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8848/nacos/"]
interval: 2s
timeout: 5s
retries: 60
networks: [fixture]

networks:
fixture:
# The Java client runs on the host. Like docker-compose.dev.yml, published
# loopback ports require a non-internal bridge. This network belongs only
# to the random disposable project; MySQL still publishes no host port.
internal: false
Loading
Loading